diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ac4020d6..a3a3f88b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ WeChat original voice can be prepared for playback, native quotes and retained l ### Added +- Paired Lark users with **Answer** capability can answer native Human questions through a DM form or an explicit single-question reference; Web and IM share the original owner and one accepted answer, with uncertain submission requiring explicit reconciliation rather than replay ([#1031](https://github.com/BotHarness/BotHarness/issues/1031), [guide](docs/lark-connection.md#answer-native-questions-in-the-management-dm)). + - A reviewed Lark operator can receive tool approvals in a named management DM and use **Allow once** or **Reject**; callbacks recheck the actual actor and exact native call, while the Profile distinguishes delivery, accepted decision and native execution result. Known-unsent retries are bounded; uncertain delivery and old cards after restart cannot authorize execution ([#1029](https://github.com/BotHarness/BotHarness/issues/1029), [ADR-0141](docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)). - AX isolated Profiles can reuse a machine-local OpenCode Go credential and native default model after one hidden-input setup, preserving independent Profile configuration; the qualified DSH 0.2.0 RC1 adapter supplies the actual Session header required for real Go replies ([#1079](https://github.com/BotHarness/BotHarness/issues/1079), [guide](docs/agents/ax-model.md)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 01268d50a0..55d5fd642d 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -9,6 +9,8 @@ ### Added +- 已配对且具有「回答」能力的 Lark 用户可通过私聊表单或明确的单问题编号回答原生 Human 提问;Web 与 IM 共用原提问者,只接受一次答案,结果不确定时必须显式核对而不重放答案([#1031](https://github.com/BotHarness/BotHarness/issues/1031),[指南](docs/lark-connection.md#answer-native-questions-in-the-management-dm))。 + - 经 Web 审核的 Lark 用户可按名称选择管理私聊,接收工具审批并点击「允许一次/拒绝」;回调复核实际点击者和准确的原生调用,Profile 分开展示通知投递、决定接受及原生执行结果。确定未发送的重试有上限,未知投递及重启后的旧卡片不能授权执行([#1029](https://github.com/BotHarness/BotHarness/issues/1029)、[ADR-0141](docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md))。 - AX 隔离 Profile 可在一次隐藏输入设置后复用本机 OpenCode Go 凭证和内置默认模型,保留各 Profile 的独立配置;经核验的 DSH 0.2.0 RC1 适配器补齐真实 Go 回复要求的实际 Session 请求头([#1079](https://github.com/BotHarness/BotHarness/issues/1079), [指南](docs/agents/ax-model.md))。 diff --git a/apps/docs/public/guides/lark/questions/after-dark.png b/apps/docs/public/guides/lark/questions/after-dark.png new file mode 100644 index 0000000000..f1f1fa5c23 Binary files /dev/null and b/apps/docs/public/guides/lark/questions/after-dark.png differ diff --git a/apps/docs/public/guides/lark/questions/after-light.png b/apps/docs/public/guides/lark/questions/after-light.png new file mode 100644 index 0000000000..0e6e5ad998 Binary files /dev/null and b/apps/docs/public/guides/lark/questions/after-light.png differ diff --git a/apps/docs/public/guides/lark/questions/before-dark.png b/apps/docs/public/guides/lark/questions/before-dark.png new file mode 100644 index 0000000000..f961d44b44 Binary files /dev/null and b/apps/docs/public/guides/lark/questions/before-dark.png differ diff --git a/apps/docs/public/guides/lark/questions/before-light.png b/apps/docs/public/guides/lark/questions/before-light.png new file mode 100644 index 0000000000..3c2ee429cb Binary files /dev/null and b/apps/docs/public/guides/lark/questions/before-light.png differ diff --git a/apps/docs/public/guides/lark/questions/native-question-answered-dark.png b/apps/docs/public/guides/lark/questions/native-question-answered-dark.png new file mode 100644 index 0000000000..85955322fe Binary files /dev/null and b/apps/docs/public/guides/lark/questions/native-question-answered-dark.png differ diff --git a/apps/docs/public/guides/lark/questions/native-question-answered-light.png b/apps/docs/public/guides/lark/questions/native-question-answered-light.png new file mode 100644 index 0000000000..d6ab162564 Binary files /dev/null and b/apps/docs/public/guides/lark/questions/native-question-answered-light.png differ diff --git a/apps/docs/public/guides/lark/questions/native-question-pending-light.png b/apps/docs/public/guides/lark/questions/native-question-pending-light.png new file mode 100644 index 0000000000..7e0d8d54e7 Binary files /dev/null and b/apps/docs/public/guides/lark/questions/native-question-pending-light.png differ diff --git a/docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md b/docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md index 29628e7c87..0ebef5223c 100644 --- a/docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md +++ b/docs/adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md @@ -36,3 +36,15 @@ The released dsh-im package version alone does not prove these capabilities. Use Real platform testing requires one exclusive app receiver. As in ADR-0136, the supervisor can reconnect a disconnected account, so either use a dedicated test application or explicitly authorize a bounded Host shutdown. Restore the original receiver after testing. Local SDK fixtures and native Web proof are distinct from real Lark callback/execution proof. Computer/Browser first-use approval installs native Session-wide authority. The broker marks these requests Web-only and sends a non-actionable management notification. They cannot be granted by an external one-operation decision. This preserves existing Web behavior while keeping IM Allow once limited to the exact operation. The per-token guard captures the canonical call and survives pre-execution tracking disposal; the native execution guard consumes it once and rechecks actual name/arguments, ownership, scope and current actor capability. + +## Native question extension: #1031 + +The private question slice reuses the revisioned management route and exclusive Consumer fanout while keeping a separate `answer` capability and fixed question namespace. `ChannelUserQuestions` remains the only owner of the native `user-questions/request` wait. A committed request notification carries canonical Channel/Session references; the Messaging question projection cannot settle a native request itself. A second durable answer queue was rejected because it could revive an old native owner or race the existing Web answer. + +Generation 61 adds only `messaging_question_deliveries`: canonical request/Session locators, original-questions hash, management route revision, checked card receipt, delivery/submission/update outcomes and bounded send count. Question content and accepted answers remain in the canonical Channel. The current SDK actor/account, paired private conversation, exact receipt, current Answer capability and original offered values are checked; a synchronous Channel commit fence rechecks the live owner and current authorization after queued writes. One native owner accepts one answer from either Web or IM. Answer-only pairings do not receive tool approval requests. + +The checked Provider renders the original questions as a Card 2.0 form with opaque option indices and one explicit submit button; it normalizes SDK `action.form_value` alongside actual operator/context. Its private checked send/update pipeline verifies native source and own-card receipt before each write. Plain text is an answer only when it quotes the original single-question card or explicitly selects its 12-character reference. Several questions, malformed references and a reference contradicting a quoted card require explicit selection. Ordinary clarification retains its source conversation; answer text is never an approval or a saved rule. See the [official Lark callback reference](https://github.com/larksuite/cli/blob/main/skills/lark-im/references/lark-im-card-action-reply.md) for transport payload facts; that external reference is not application authority. + +A failed or uncertain submission retains the original live wait. An uncertain post-commit answer refuses blind duplicate submission; authenticated `questionRetry` explicitly reconciles the same original owner with its existing canonical answer, or confirms that it still waits before allowing a new submission. It never replays stored answer text. Known-unsent sends and known-failed updates allow explicit bounded repair; unknown sends are not retried. Restart expires pending notification controls, refreshes the original receipt when its checked route is still available and never restores the native Promise. The native wait remains blocking in this slice. + +Local native Windows/model proof and checked SDK fixtures remain distinct from real Lark form/callback qualification. A new Human-approved exclusive receiver window is required for #1031; the completed #1029 approval test does not authorize a second outage or qualify question forms. Generation 61 is another one-way writer boundary: older Bundles require their matching pre-upgrade Profile snapshot. diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index ebf492b007..9bb197422a 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -247,10 +247,12 @@ PersonaBot Profile 的 IM 连接经现有 Typert/API Gateway 选择账号和已 首次能力只能由认证 Web Human 显式勾选并按当前申请 revision 审核;定位码不能兑换权限,首位申请者不会自动成为管理员。能力仅覆盖当前 Bot 的批准、拒绝、回答及保存规则资格,不扩展 VPS、原生 DSH API、其他 Bot 或审批人管理。每次使用重查当前 Binding、Bot、真实 actor、能力和状态;暂停期间不可用,永久撤销后重新配对必须重新审核。该切片交付配对与 Web 审核,IM 审批/提问控件仍由后续切片接入原生权威。参数、恢复及审计边界见 [ADR-0136](../adr/0136-lark-pairing-is-reviewed-bot-scoped-operational-authority.md)。 -### Lark 私聊审批(ADR-0141) +### Lark 私聊审批与原生提问(ADR-0141) [#1029](https://github.com/BotHarness/BotHarness/issues/1029) 将已提交的工具审批发往 Web 显式选择的已配对管理私聊;通知路由及回执由 Messaging owner 在现有 Operational Database generation 60 保存,完整请求/决定仍归 canonical Channel 与 native Session。通知、决定接受、原生执行结果和卡片更新分别投影。现有独占 Consumer fanout 单独处理官方 SDK 卡片操作,不建立普通 Source Event、Inbox 或 Memory。公开的受检查 Provider capability 负责原始私聊/实际发送人、自己的卡片回执和平台写入前 fence;Host 校验实际点击者的当前 Bot/Binding/fingerprint/配对能力与 revision、目的地、request↔receipt、完整操作及原生执行者。允许一次/拒绝重入现有工具审批 broker,Web 与 IM 共用一位胜出者,执行前再次核验撤权与完整参数。回调确认不等于批准,批准不等于成功执行;重启使旧卡片失效,未知发送结果不重发。该切片不保存规则、不处理群审批/原生问题,也不释放 native wait。见 [ADR-0141](../adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)。 +[#1031](https://github.com/BotHarness/BotHarness/issues/1031) 另以 `answer` 能力与固定 question namespace 接入同一管理私聊。原生 `user-questions/request` 的 application-defined owner 仍为 `ChannelUserQuestions`:仅发布已提交请求,IM 回调校验真实 SDK actor、当前路由/配对、原卡片回执与原始选项,再以同步 commit fence 复核实时 owner 和能力。Web/IM 只能写入一次 canonical Channel 回答并恢复同一 native wait;答案文字不能产生工具批准。Generation 61 的 `messaging_question_deliveries` 只保存请求 hash、路由/回执及投递/提交/更新结果,不建立第二份问题或答案权威。只有明确编号或引用原单问题卡片的文字进入回答控制路径;歧义必须显式选择,普通澄清保持原来源。结果不确定时,认证 `questionRetry` 核对原 owner 与已有 canonical 回答,不重放答案;重启只更新失效投影,不恢复旧 Promise。该切片仍使用原生等待;真实 Lark 表单/回调需独立真人窗口核验。 + ### Bot 之间的 Channel 协作(ADR-0065) Orchestrator Session 中注册的 `group_attention_get` 与 `group_attention_set` 是 DSH model-facing Tool,消费 BotHarness Host 的 application-defined Channel capability;Host 从该 Session 所属 PersonaBot 确定 actor,并在读写时复核当前 Group membership。未保存的 Group 偏好只读为 digest 5 条/30 秒、revision 0;Human Bridge 写入和 Bot Tool 写入共用 Channel record 中的 `wakePolicies`,每次实质变更在同一 SQLite 事务里追加不可改写的 actor/时间/revision 审计事实。审计表只存历史,不作为第二套当前偏好;Admission 在消息提交时固定有效策略与 revision,不会随之后的编辑回写。 diff --git a/docs/evidence/issue-1031/native-shell-measure.json b/docs/evidence/issue-1031/native-shell-measure.json new file mode 100644 index 0000000000..a00f2721f4 --- /dev/null +++ b/docs/evidence/issue-1031/native-shell-measure.json @@ -0,0 +1,57 @@ +[ + { + "kind": "SECTION", + "label": "Lark 管理通知", + "width": 804, + "height": 269, + "padding": "16px 0px", + "fontSize": "13px", + "color": "rgb(249, 250, 251)", + "background": "rgba(0, 0, 0, 0)", + "borderRadius": "0px" + }, + { + "kind": "BUTTON", + "label": null, + "width": 72, + "height": 28, + "padding": "0px 10px", + "fontSize": "13px", + "color": "rgb(249, 250, 251)", + "background": "rgba(84, 85, 87, 0.5)", + "borderRadius": "8px" + }, + { + "kind": "BUTTON", + "label": null, + "width": 85, + "height": 28, + "padding": "0px 10px", + "fontSize": "13px", + "color": "rgb(249, 250, 251)", + "background": "rgba(0, 0, 0, 0)", + "borderRadius": "8px" + }, + { + "kind": "BUTTON", + "label": null, + "width": 98, + "height": 28, + "padding": "0px 10px", + "fontSize": "13px", + "color": "rgb(249, 250, 251)", + "background": "rgba(84, 85, 87, 0.5)", + "borderRadius": "8px" + }, + { + "kind": "SECTION", + "label": "Lark 提问通知", + "width": 804, + "height": 115, + "padding": "16px 0px", + "fontSize": "13px", + "color": "rgb(249, 250, 251)", + "background": "rgba(0, 0, 0, 0)", + "borderRadius": "0px" + } +] diff --git a/docs/evidence/issue-1031/windows-local.json b/docs/evidence/issue-1031/windows-local.json new file mode 100644 index 0000000000..c166dc4137 --- /dev/null +++ b/docs/evidence/issue-1031/windows-local.json @@ -0,0 +1,72 @@ +{ + "issue": 1031, + "date": "2026-10-07", + "environment": { + "os": "native Windows", + "node": "24.21.0", + "dsh": "0.2.0-rc.1", + "baseline": "009e020becc360763a8506fa1d95024b847e9c72" + }, + "provider": { + "source": "8f1c67b65ee6d112e068babe945037f5ddea54a6", + "pr": "https://github.com/DoodleBears/dsh-im/pull/14", + "runtimeFiles": 400, + "runtimeSha256": "b9c66ec730b5755fd406aba05fa58c1643b596904aba64639c2839f35c0fab0f", + "packageSha256": "89e5f1a5f7a31a33927d07870338dfebd50ab2ad0d65cde08ad81d0ce55af4c1" + }, + "checks": { + "focusedBotHarness": { + "uniqueFiles": 10, + "uniqueTestsPassed": 194 + }, + "checkedProvider": { + "testsPassed": 791, + "existingPlatformSkips": 5, + "failures": 0 + }, + "lint": true, + "format": true, + "typecheck": true, + "build": true, + "fullRepositorySuitesClaimedGreen": false + }, + "nativeWeb": { + "questionCount": 2, + "canonicalResolutionCount": 1, + "selectedOption": "Main", + "customAnswer": "Remember the Windows question tracer", + "questionToolCalls": 1, + "nativeAnswerError": false, + "originalSessionContinued": true, + "turnEnded": true + }, + "receiverProtection": { + "exercisedWhileImDisabled": true, + "typedUtcTimestampValidated": true, + "exactQaHostStopped": true, + "disabledStatePersisted": true, + "candidateRestartAuthenticated": true + }, + "realLarkQuestionsQualified": false, + "productionOutageStarted": false, + "sourceSha256": { + "packages/core/src/channels/user-questions.ts": "fd5617d92b1ec0ab771c2ab2fc748d5868e620b12657053e05720088a134224b", + "packages/core/src/messaging/question-messaging.ts": "095ec5f79237c07543bbe730a98018cf8c99fd0d860d7ee8f5452a20738a5178", + "packages/core/src/messaging/dsh-im.ts": "60059fe5e1ae9a9ec9bfc9a31813a41d71fdf39afbe14a4742b4cdd99cd20c79", + "packages/client/src/client/question-notifications.tsx": "f1d5ad47b7aec124e663f10db5431ed682a5339d0b179e93a54b951231da82e6" + }, + "screenshotState": { + "viewport": "1440x960", + "locale": "zh", + "themes": ["light", "dark"], + "managementRoute": null, + "imBindings": 0, + "olderQaSidebarHistoryDiffers": true + }, + "limitations": [ + "Real Lark form submission, SDK callback and final card review require a fresh Human-authorized exclusive window.", + "The broader Windows suites have pre-existing cleanup/updater/POSIX failures; focused results do not claim full-suite success.", + "Question notification metadata is schema generation 61; an older schema-60 binary is not a safe database downgrade.", + "Native waits remain blocking; no Inbox continuation qualification is claimed." + ] +} diff --git a/docs/evidence/issue-1031/windows-platform-attempt-1.json b/docs/evidence/issue-1031/windows-platform-attempt-1.json new file mode 100644 index 0000000000..a7af9ccc48 --- /dev/null +++ b/docs/evidence/issue-1031/windows-platform-attempt-1.json @@ -0,0 +1,53 @@ +{ + "issue": 1031, + "attempt": 1, + "rootSource": "9717d6f460fe41c5598d49d4b90e462cb0a9496b", + "providerSource": "8f1c67b65ee6d112e068babe945037f5ddea54a6", + "nativeWindows": true, + "humanAuthorized": true, + "serverRestoreSeconds": 540, + "localCutoffSeconds": 480, + "windowArmedAtUTC": "2026-10-07T07:57:45.88Z", + "productionRestorationVerifiedAtUTC": "2026-10-07T08:05:36.385Z", + "outageUpperBoundSeconds": 471, + "freshPairingVerified": true, + "capabilities": ["answer"], + "nativeQuestionCount": 2, + "platformResult": { + "code": 11310, + "errorPath": "ROOT -> body -> elements -> [1](tag: form)", + "reason": "max_length 2000 exceeds the default maximum 1000", + "cardCreationAccepted": false + }, + "deliveryProjection": "unknown-outcome", + "deliveryReplay": false, + "canonicalAnswers": 0, + "afterRestartNativeQuestion": "expired", + "qaCleanup": { + "routeDisabled": true, + "pairingsRevoked": 1, + "identitiesUnbound": 1, + "providerDisabled": true, + "errors": [], + "localReceiverStopped": true + }, + "production": { + "serviceRestored": true, + "withinTenMinutes": true, + "humanConfirmedLarkAndDiscord": true + }, + "followup": { + "providerSource": "a9f6cbc72369df7b522658158dc102ad66826ebe", + "formInputMaximum": 1000, + "sdkFormCustomMaximum": 1000, + "explicitTextNativeMaximumUnchanged": 2000, + "checkedProviderTestsPassed": 791, + "existingPlatformSkips": 5, + "packageVerified": true, + "realLarkSubmissionQualified": false, + "freshWindowRequired": true, + "runtimeFiles": 400, + "runtimeSha256": "0432ab4075100b87bb2d383b7d232fd059790dcda6f63ac9ddf890a5b93f3039", + "packageSha256": "675d82faf0f92f8da7cf82bc15d4d09334dc76252c3bfff9f43a388856d251f5" + } +} diff --git a/docs/lark-connection.md b/docs/lark-connection.md index ea192ec80c..15f3bda970 100644 --- a/docs/lark-connection.md +++ b/docs/lark-connection.md @@ -344,11 +344,11 @@ _Close/reopen and same-Profile restart retained configuration/history; select th ## Handle tool approvals in a management DM -This slice supports Lark private **Allow once** and **Reject** cards through a qualified Provider. Group approvals, native question forms, saved automatic rules and non-blocking native waits are separate slices. The released dsh-im package number alone does not imply card capability; an unavailable Provider remains unavailable. +This slice supports Lark private **Allow once** and **Reject** cards through a qualified Provider. Native question forms use the separate Answer capability described below. Group approvals, saved automatic rules and non-blocking native waits are separate slices. The released dsh-im package number alone does not imply card capability; an unavailable Provider remains unavailable. 1. Enable the app's **Events & callbacks → Callback configuration → Long connection** and add `card.action.trigger`, then publish the version. Retain the existing message read/send scopes and add `im:chat:read` so the sender can verify a private conversation. A maintainer must authorize these app changes. 2. Send `/pair` in the Bot's Lark DM. In the authenticated Web Profile, inspect the real applicant/account and explicitly grant **Approve** and/or **Reject**. Pairing does not create ordinary DM intake or grant VPS/API access. -3. In **Lark approval notifications**, choose that person's name and receiving account, then **Save destination**. Select **Send test card**; it has no approval buttons and grants nothing. +3. In **Lark management notifications**, choose that person's name and receiving account, then **Save destination**. Select **Send test card**; it has no approval buttons and grants nothing. 4. A subsequent native tool approval sends its complete operation to that management DM, with **Allow once** and **Reject**. Check the proposed operation before deciding. A truncated card asks you to inspect the complete operation in Web. The card's acknowledgement only confirms receipt of your click; the final native decision and result are separate. 5. Refresh notifications in the Profile and use **Open native session and complete operation** to inspect the actual native result. **Decision accepted** is not proof that a tool ran. Rejected, revoked, expired, duplicate or mismatched actions cannot approve a new call. @@ -368,6 +368,30 @@ In the earlier isolated 2026-10-07 test, the actual Lark platform accepted both [Light theme recovery screenshot](/guides/lark/approvals/recovery-light.jpg). After the test authority was revoked and the local Host restarted with its IM Provider disabled, the destination is **Off**, the old request is **Expired**, and the local identity is unavailable. This screen does not prove production availability; production Discord/Lark connections were verified separately after restoration. Do not use the old card for a new test. +## Answer native questions in the management DM + +The #1031 candidate adds a separate checked question-card contract. A Provider qualified for approval cards alone cannot claim question support. This candidate does not change the published Provider pin or enable a second receiver. + +1. Send `/pair` in the Bot's Lark DM and explicitly grant **Answer** in authenticated Web. Approve/Reject and Answer are independent. Select this pairing in **Lark management notifications**; ordinary DM reception remains separately configured. +2. When this Bot's original Orchestrator asks a native Human question, its committed request appears in Web and in the configured private DM. Choose an offered option or enter a custom answer for every question, then select **Submit answers**. Each Lark form input supports up to 1000 characters. For a single-choice question, clear the choice before submitting a custom answer. A multi-select question may include choices and a custom answer together. +3. A single question can also receive a plain-text reply to its original card, or `/answer <12-character reference> `. Without an exact reference or original-card reply, ordinary clarification stays ordinary conversation. Several open questions, a multi-question request, malformed commands or a reference that contradicts the quoted card require explicit selection; the system does not guess. +4. The click acknowledgement means queued, not answered. The original native owner rechecks the actual actor, Answer capability, current account/route, original receipt, offered values and live Session at commit. Web and Lark can accept only one canonical answer. Answer text never creates tool approval or an automatic rule. +5. In **Lark question notifications**, inspect native status separately from delivery and answer submission. **Check original question** reconciles an uncertain submission against the same live native owner and existing canonical answer; it never replays the submitted text. A failed attempt leaves the original question available in Web. Uncertain card sends are not resent. Known-unsent notification or update failures can be repaired explicitly, with at most three sends. + +Cancelling the native question updates its original card when the checked route remains available. Restart expires a pending native owner and refuses old controls rather than restoring a Promise from a notification row. Card update failure does not change the canonical answer; inspect Web and the native Session. This slice continues to use a native wait and does not claim non-blocking Inbox continuation. + +Qualification is source-specific: focused native-owner/checked-Provider tests and a native Windows Web/model run are local evidence. The Lark form, actual SDK callback and final card state still require a new authorized, exclusive receiver window and Human operation. The completed #1029 approval window does not qualify #1031 questions. Production Lark and Discord remain on their restored receiver until that window is approved. + +The first authorized #1031 platform window verified a fresh pairing and **Answer only**, then Lark rejected the form before card creation with error `11310`: the original 2000-character input setting exceeded its 1000-character maximum. No answer was accepted; the unknown delivery was not replayed. QA authority was revoked, its identity unbound and receiver stopped before restoring production within ten minutes. The candidate now renders and validates form inputs at 1000 characters; real form submission and final card qualification still await a new window. + +![The candidate's real native Profile shows separate question notifications, with reception disabled](/guides/lark/questions/after-light.png) + +[Dark theme](/guides/lark/questions/after-dark.png). The matched [light baseline](/guides/lark/questions/before-light.png) and [dark baseline](/guides/lark/questions/before-dark.png) use the merged #1029 source, the same QA Bot name, locale and 1440 × 960 viewport. Both Profiles have no management route or IM binding; older QA entries in the candidate's sidebar are incidental history. These are local setup screenshots, not Lark delivery evidence. + +![The original native question owner accepts an option and custom text in the Windows Web run](/guides/lark/questions/native-question-answered-light.png) + +[Pending native question](/guides/lark/questions/native-question-pending-light.png) · [Answered in dark theme](/guides/lark/questions/native-question-answered-dark.png). One canonical answer resolved the original native tool, and that Session continued to a completed turn. The native Web form is the pre-existing answer surface; these screenshots do not claim real Lark form qualification. + ## Images in Channel history In the image-capable #1021 candidate, an authorized Lark image or supported image-bearing post appears inside its original Channel bubble. The source name above it still opens source details. Images load when visible; select an image to enlarge it, and use **Retry** after a failed load. Text and multiple images stay in their native order in one message. diff --git a/docs/lark-connection.zh.md b/docs/lark-connection.zh.md index c0f1a778d8..4fb5011ea2 100644 --- a/docs/lark-connection.zh.md +++ b/docs/lark-connection.zh.md @@ -346,11 +346,11 @@ _关闭重开和重启同一 Profile 后配置历史保留;重新加载后选 ## 在管理私聊处理工具审批 -此切片经受资格验证的 Provider 支持 Lark 私聊「允许一次/拒绝」卡片。群审批、原生提问表单、保存自动规则和释放原生等待由后续切片实现;dsh-im 已发布版本号本身不能证明卡片能力,能力不可用时不会回退放行。 +此切片经受资格验证的 Provider 支持 Lark 私聊「允许一次/拒绝」卡片。原生提问表单使用下文独立的「回答」能力。群审批、保存自动规则和释放原生等待由后续切片实现;dsh-im 已发布版本号本身不能证明卡片能力,能力不可用时不会回退放行。 1. 在应用的「事件与回调 → 回调配置」选择长连接,添加 `card.action.trigger` 并发布版本。保留原有读消息/发消息权限,增加 `im:chat:read` 以在发送前验证确为私聊。这些应用变更须经维护者授权。 2. 在 Bot 的 Lark 私聊发送 `/pair`;认证 Web Profile 核对真实申请人和应用账号,明确授予「批准」及/或「拒绝」。配对不会建立普通私聊收件,也不授予 VPS/API 权限。 -3. 在「Lark 审批通知」按姓名和接收账号选择管理私聊,点击「保存目的地」,再「发送测试卡片」。测试卡片没有审批按钮,不批准任何操作。 +3. 在「Lark 管理通知」按姓名和接收账号选择管理私聊,点击「保存目的地」,再「发送测试卡片」。测试卡片没有审批按钮,不批准任何操作。 4. 后续原生工具审批会将完整操作发到该管理私聊,提供「允许一次/拒绝」。决定前核对操作;内容截断时到 Web 查看完整内容。点击后的提示仅说明操作已收到,正式决定和执行结果分开确认。 5. 在 Profile 刷新通知,点击「查看原生会话与完整操作」检查原生结果。「已批准」不代表工具已执行。拒绝、撤权、过期、重复或身份不匹配的操作均不能批准新调用。 @@ -370,6 +370,30 @@ Computer/Browser 的首次授权覆盖原生会话,因此通知不提供审 [查看浅色恢复截图](/guides/lark/approvals/recovery-light.jpg)。测试授权撤销后,本机 Host 在禁用 IM Provider 的状态下重启:目的地为「关闭自动通知」,旧请求「已失效」,本机身份暂不可用。这张截图不证明生产连接状态;恢复后的生产 Discord/Lark 连接已另行核验。新测试不能使用旧卡片。 +## 在管理私聊回答原生提问 + +#1031 候选增加独立的受检查提问卡片契约。仅通过审批卡验证的 Provider 不能宣称支持提问;此候选不更新已发布 Provider 固定版本,也不启用第二个接收器。 + +1. 在 Bot 的 Lark 私聊发送 `/pair`,并在认证 Web 显式授予「回答」能力。「批准/拒绝」与「回答」互相独立。在「Lark 管理通知」选择此配对;普通聊天接收仍另行配置。 +2. Bot 原来的 Orchestrator 发起原生 Human 提问后,已提交的问题同时出现在 Web 和所选私聊。为每个问题选择提供的选项或填写自己的答案,再点击「提交答案」。Lark 表单的每个输入框最多支持 1000 字。单选问题填写自定义答案前须清空已有选项;多选问题可同时提交选项与自定义答案。 +3. 单个问题也可用纯文本回复原卡片,或发送 `/answer <12 位编号> <答案>`。没有准确编号或原卡片引用时,普通澄清仍留在普通聊天。多个待答请求、包含多个问题的请求、格式错误或编号与引用矛盾时,必须明确选择,系统不会猜测。 +4. 点击确认只表示已排队,不代表已回答。原生提问服务在提交时复查实际用户、回答能力、当前账号/路由、原回执、选项值及实时 Session。Web 和 Lark 只能提交一次权威答案;答案文字不会产生工具批准或自动规则。 +5. 「Lark 提问通知」分开显示原生状态、卡片发送与答案提交。「核对原提问」使用同一实时提问者和既有权威记录核对未知结果,不重放答案。失败仍保留原提问,可在 Web 回答。未知卡片发送不重发;确定未发送或更新失败时可显式修复,发送最多三次。 + +取消原生提问后,受检查路由仍可用时会更新原卡片。重启使仍等待的原生提问者失效并拒绝旧控件,不从通知行恢复 Promise。卡片更新失败不改变权威答案,请核对 Web 和原生 Session。此切片仍使用原生等待,不宣称释放 Inbox 继续处理。 + +验证按准确源版本记录:原生提问者/受检查 Provider 回归及原生 Windows Web/模型运行属于本地证据。Lark 表单、真实 SDK 回调和最终卡片仍须新的独占接收窗口与真人操作;已完成的 #1029 审批窗口不能证明 #1031 提问。批准新窗口前,生产 Lark 和 Discord 保持已恢复的接收器。 + +首次获准的 #1031 平台窗口核对了新配对并仅授予「回答」,随后 Lark 在创建卡片前返回 `11310`:原 2000 字输入设置超过平台最多 1000 字的限制。未接受任何答案,也未重放未知投递;先撤销 QA 权限、解绑身份并停止接收器,再于十分钟内恢复生产。候选已将表单渲染及输入校验收窄至 1000 字;实际表单提交与最终卡片仍待新窗口核验。 + +![真实原生 Profile 分开显示提问通知,接收保持禁用](/guides/lark/questions/after-light.png) + +[深色主题](/guides/lark/questions/after-dark.png)。配对的[浅色基线](/guides/lark/questions/before-light.png)和[深色基线](/guides/lark/questions/before-dark.png)使用已合并 #1029 源码、相同 QA Bot 名称、语言及 1440 × 960 视口。两个 Profile 均没有管理路由或 IM 绑定;候选侧栏中的旧 QA 条目是历史记录。这些本机设置截图不证明 Lark 投递。 + +![Windows Web 运行中,原来的原生提问者接受选项与自定义文字](/guides/lark/questions/native-question-answered-light.png) + +[等待回答的原生提问](/guides/lark/questions/native-question-pending-light.png) · [深色已回答状态](/guides/lark/questions/native-question-answered-dark.png)。一份权威答案结束原工具等待,原 Session 随后继续并完成回合。Web 表单是原有回答入口,这些截图不宣称真实 Lark 表单已验收。 + ## Channel 历史中的图片 在支持图片的 #1021 候选版本中,已授权的 Lark 图片或受支持的图文消息会显示在原 Channel 气泡内。上方的来源名称仍可打开来源详情。图片进入可见区域时加载;点击图片可放大,加载失败后可点击「重试」。文字和多张图片按原生顺序保留在一条消息中。 diff --git a/packages/client/src/client/actions.ts b/packages/client/src/client/actions.ts index 13845c3f89..0dfff95516 100644 --- a/packages/client/src/client/actions.ts +++ b/packages/client/src/client/actions.ts @@ -8,6 +8,7 @@ import { setApprovalRoute, testApprovalRoute, retryApprovalNotification, + retryQuestionNotification, } from './bridge.js'; import type { GroupMemberWakePolicy } from '../../../core/src/channels/channel.js'; import type { @@ -265,6 +266,7 @@ export interface BridgeActions { approvalRoute(slug: string, pairingId: string | null, expectedRevision: number): Promise; approvalTest(slug: string): Promise; approvalRetry(slug: string, id: string): Promise; + questionRetry(slug: string, id: string): Promise; pairingReview(slug: string, input: PairingReviewInput): Promise; messagingIdentity(slug: string, input: MessagingIdentityInput): Promise; messagingSnapshot(slug: string): Promise; @@ -1792,6 +1794,7 @@ export function createActions( setApprovalRoute(call, slug, pairingId, expectedRevision), approvalTest: (slug) => testApprovalRoute(call, slug), approvalRetry: (slug, id) => retryApprovalNotification(call, slug, id), + questionRetry: (slug, id) => retryQuestionNotification(call, slug, id), pairingReview: (slug, input) => reviewPairing(call, slug, input), messagingIdentity: (slug, input) => manageMessagingIdentity(call, slug, input), messagingSnapshot: (slug) => loadMessagingSnapshot(call, slug), diff --git a/packages/client/src/client/bridge.ts b/packages/client/src/client/bridge.ts index 8d72ac1590..807a7126f6 100644 --- a/packages/client/src/client/bridge.ts +++ b/packages/client/src/client/bridge.ts @@ -3436,6 +3436,13 @@ export async function setApprovalRoute( export async function testApprovalRoute(call: BridgeCall, slug: string): Promise { await unwrap(call, 'approvalTest', { slug }); } +export async function retryQuestionNotification( + call: BridgeCall, + slug: string, + id: string, +): Promise { + await unwrap(call, 'questionRetry', { slug, id }); +} export async function retryApprovalNotification( call: BridgeCall, slug: string, diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index f69fa07d45..b85b926179 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -3,13 +3,28 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'; export const LOCALE_NS = 'botharness'; export const zh = { - 'approvalIm.title': 'Lark 审批通知', + 'questionIm.title': 'Lark 提问通知', + 'questionIm.hint': + '同一管理私聊可回答原生提问,需要单独授予“回答”能力。原会话接收答案后才会显示已回答。', + 'questionIm.empty': '暂无提问通知。', + 'questionIm.reconcile': '核对原提问', + 'questionIm.repair': '修复通知', + 'questionIm.status.pending': '原提问等待回答', + 'questionIm.status.answered': '已回答', + 'questionIm.status.cancelled': '已取消', + 'questionIm.status.expired': '已失效', + 'questionIm.submission.none': '尚未提交答案', + 'questionIm.submission.submitting': '正在提交答案', + 'questionIm.submission.accepted': '原会话已接受答案', + 'questionIm.submission.refused': '答案未被接受,原提问继续等待', + 'questionIm.submission.unknown-outcome': '答案结果未确认;核对后再继续,系统不会重放答案。', + 'approvalIm.title': 'Lark 管理通知', 'approvalIm.hint': - '选择已配对且获准处理审批的私聊用户。每个工具审批请求都会自动发到此处;普通聊天接收另行配置。', + '选择已配对的管理私聊。审批和提问分别按批准、拒绝与回答能力发送;普通聊天接收另行配置。', 'approvalIm.refresh': '刷新通知', 'approvalIm.destination': '管理私聊', 'approvalIm.off': '关闭自动通知', - 'approvalIm.empty': '先在私聊发起 /pair,再在 Web 授予批准或拒绝能力。', + 'approvalIm.empty': '先在私聊发起 /pair,再在 Web 授予批准、拒绝或回答能力。', 'approvalIm.save': '保存目的地', 'approvalIm.test': '发送测试卡片', 'approvalIm.once': @@ -2029,13 +2044,30 @@ export function zhTranslate(key: BotHarnessKey | string, params?: Record operate(async () => undefined, 'approval')} openSession={actions.openSession} /> + operate(() => actions.questionRetry(slug, id), 'approval')} + openSession={actions.openSession} + /> ; + openSession(id: string): void; + t: BotHarnessTranslate; +}): ReactElement { + return ( +
+ {t('questionIm.title')} +

{t('questionIm.hint')}

+ {deliveries.length === 0 ?

{t('questionIm.empty')}

: null} + {deliveries.map((delivery) => { + const canRepair = + (delivery.submission === 'unknown-outcome' && + ['pending', 'answered'].includes(delivery.status)) || + (delivery.delivery === 'failed' && + delivery.attempts < 3 && + delivery.status === 'pending') || + (delivery.receipt !== undefined && delivery.update === 'failed'); + return ( +
+
+ {delivery.reference} + {t(`questionIm.status.${delivery.status}`)} + {t(`approvalIm.delivery.${delivery.delivery}`)} +
+

{t(`questionIm.submission.${delivery.submission}`)}

+ {delivery.update === 'unknown-outcome' || delivery.update === 'failed' ? ( +

{t('approvalIm.updateUnconfirmed')}

+ ) : null} + {delivery.delivery === 'unknown-outcome' ? ( +

{t('approvalIm.unknownHint')}

+ ) : null} +
+ + {canRepair ? ( + + ) : null} +
+
+ ); + })} +
+ ); +} diff --git a/packages/client/test/bot-sidebar.test.ts b/packages/client/test/bot-sidebar.test.ts index da4c843c59..ac49559f55 100644 --- a/packages/client/test/bot-sidebar.test.ts +++ b/packages/client/test/bot-sidebar.test.ts @@ -142,6 +142,7 @@ function stubActions(): BridgeActions { approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingSnapshot: vi.fn(async () => ({ accounts: [], grants: [], intents: [] })), diff --git a/packages/client/test/messaging-profile.test.ts b/packages/client/test/messaging-profile.test.ts index 34b6e75599..8ed712e318 100644 --- a/packages/client/test/messaging-profile.test.ts +++ b/packages/client/test/messaging-profile.test.ts @@ -74,6 +74,7 @@ it('WeChat Profile exposes only qualified proactive sending and separates client approvalRoute: vi.fn(), approvalTest: vi.fn(), approvalRetry: vi.fn(), + questionRetry: vi.fn(), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingGroupPolicy: vi.fn(), @@ -181,6 +182,7 @@ it('requires explicit target authorization and an explicit send; unknown outcome | 'approvalRoute' | 'approvalTest' | 'approvalRetry' + | 'questionRetry' | 'pairingReview' | 'messagingIdentity' | 'messagingSnapshot' @@ -197,6 +199,7 @@ it('requires explicit target authorization and an explicit send; unknown outcome approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingSnapshot: async () => snapshot, @@ -338,6 +341,7 @@ it('changes group intake only after the Human toggles it and can stop it when th approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingSnapshot: async () => snapshot, @@ -443,6 +447,7 @@ it.each(['test', 'slack'])( approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingSnapshot: async (): Promise => ({ @@ -571,6 +576,7 @@ it('keeps native Thread management available after a grant migrates to Channel B approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingSnapshot: async () => snapshot, @@ -697,6 +703,7 @@ it.each(['report', 'reply'] as const)( approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(), messagingIdentity: vi.fn(), messagingGroupPolicy: vi.fn(), @@ -814,6 +821,7 @@ it('shows a rejected pairing review beside its controls and clears it after a su approvalRoute: vi.fn(async () => {}), approvalTest: vi.fn(async () => {}), approvalRetry: vi.fn(async () => {}), + questionRetry: vi.fn(async () => {}), pairingReview: vi.fn(async () => { throw new Error('pairing-stale'); }), diff --git a/packages/client/test/question-notifications.test.ts b/packages/client/test/question-notifications.test.ts new file mode 100644 index 0000000000..710f030ae3 --- /dev/null +++ b/packages/client/test/question-notifications.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act, createElement, type ButtonHTMLAttributes, type PropsWithChildren } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import type { QuestionDelivery } from '../../core/src/messaging/question-messaging.js'; +vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({ + Button: (props: ButtonHTMLAttributes) => createElement('button', props), + Tag: ({ children }: PropsWithChildren) => createElement('span', null, children), +})); +import { QuestionNotifications } from '../src/client/question-notifications.js'; +import { zhTranslate } from '../src/client/locale.js'; + +it('offers explicit native reconciliation for uncertain submission but never resends uncertain delivery', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const repair = vi.fn(async () => undefined), + openSession = vi.fn(); + const base: QuestionDelivery = { + id: 'question-1', + botSlug: 'ada', + sessionId: 'native-session', + reference: 'A'.repeat(12), + questionHash: 'a'.repeat(64), + route: { + botSlug: 'ada', + revision: 1, + pairingId: 'pairing', + pairingRevision: 2, + bindingId: 'binding', + }, + createdAt: '2026-10-07T00:00:00Z', + status: 'pending', + delivery: 'unknown-outcome', + attempts: 1, + submission: 'none', + update: 'none', + }; + try { + await act(async () => + root.render( + createElement(QuestionNotifications, { + deliveries: [base], + busy: false, + repair, + openSession, + t: zhTranslate, + }), + ), + ); + expect(host.querySelectorAll('button')).toHaveLength(1); + expect(host.textContent).toContain(zhTranslate('approvalIm.unknownHint')); + await act(async () => host.querySelector('button')!.click()); + expect(openSession).toHaveBeenCalledWith('native-session'); + expect(repair).not.toHaveBeenCalled(); + await act(async () => + root.render( + createElement(QuestionNotifications, { + deliveries: [ + { ...base, delivery: 'sent', status: 'answered', submission: 'unknown-outcome' }, + ], + busy: false, + repair, + openSession, + t: zhTranslate, + }), + ), + ); + const reconcile = [...host.querySelectorAll('button')].find( + (button) => button.textContent === zhTranslate('questionIm.reconcile'), + )!; + await act(async () => reconcile.click()); + expect(repair).toHaveBeenCalledWith('question-1'); + expect(host.textContent).toContain(zhTranslate('questionIm.submission.unknown-outcome')); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 55e23ca0a4..a593798f26 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -308,6 +308,7 @@ export interface BridgeError { export type BridgeResult = { ok: true; value: T } | { ok: false; error: BridgeError }; export interface BridgeMethods { + questionRetry(payload: unknown): Promise>; approvalRoute(payload: unknown): Promise>; approvalTest( payload: unknown, @@ -1070,6 +1071,17 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { delivery: await service.approvals.test(input.data.slug), })); }, + questionRetry(payload) { + const input = z + .object({ slug: z.string().min(1), id: z.string().uuid() }) + .strict() + .safeParse(payload); + if (!input.success) return Promise.resolve(invalidInput('Invalid question notification')); + return messagingCall(async (service) => { + await service.questions.retry(input.data.slug, input.data.id); + return { updated: true as const }; + }); + }, approvalRetry(payload) { const input = z .object({ slug: z.string().min(1), id: z.string().uuid() }) diff --git a/packages/core/src/bridge/rpc.ts b/packages/core/src/bridge/rpc.ts index 2c44aa609d..2c5bcd789d 100644 --- a/packages/core/src/bridge/rpc.ts +++ b/packages/core/src/bridge/rpc.ts @@ -218,6 +218,9 @@ export class BotharnessBridgeService extends TypertRemoteService { ): Promise<{ delivery: import('../messaging/approval-messaging.js').ApprovalDelivery }> { return unwrapAsync(this.methods.approvalRetry({ slug, id })); } + questionRetry(slug: string, id: string): Promise<{ updated: true }> { + return unwrapAsync(this.methods.questionRetry({ slug, id })); + } pairingReview(slug: string, input: PairingReviewInput): Promise<{ pairing: PairingRequest }> { return unwrapAsync(this.methods.pairingReview({ slug, input })); } @@ -992,6 +995,7 @@ markRemoteMethods(BotharnessBridgeService.prototype, [ 'approvalRoute', 'approvalTest', 'approvalRetry', + 'questionRetry', 'pairingReview', 'messagingSnapshot', 'messagingTargets', diff --git a/packages/core/src/channels/channel.ts b/packages/core/src/channels/channel.ts index 60e0be95c1..e77dac067d 100644 --- a/packages/core/src/channels/channel.ts +++ b/packages/core/src/channels/channel.ts @@ -608,6 +608,11 @@ export function isChannelMessage(value: unknown): value is ChannelMessage { if (questionResolution !== undefined) { if (typeof questionResolution !== 'object' || questionResolution === null) return false; const resolution = questionResolution as Record; + if ( + resolution['actor'] !== undefined && + (resolution['state'] !== 'answered' || !parseToolApprovalActor(resolution['actor'])) + ) + return false; if ( typeof resolution['requestMessageId'] !== 'string' || message['replyTo'] !== resolution['requestMessageId'] || diff --git a/packages/core/src/channels/store.ts b/packages/core/src/channels/store.ts index f79d9f4955..da423d7625 100644 --- a/packages/core/src/channels/store.ts +++ b/packages/core/src/channels/store.ts @@ -355,6 +355,7 @@ export interface ChannelStore { id: string, message: ChannelMessage, origin?: ChannelMessageOrigin, + beforeCommit?: () => boolean, ): Promise; readMessages(id: string, options?: ChannelReadOptions): ChannelMessage[]; queryMessages(id: string, options?: ChannelMessageQueryOptions): ChannelMessageQueryPage; @@ -770,7 +771,7 @@ export function createChannelStore(options: ChannelStoreOptions): ChannelStore { write(renamed); return renamed; }, - appendMessage(id, message, origin) { + appendMessage(id, message, origin, beforeCommit) { return enqueue(id, () => { const record = read(id); if (record === undefined) return undefined; @@ -783,6 +784,7 @@ export function createChannelStore(options: ChannelStoreOptions): ChannelStore { } assertAttachmentRefs(message.attachments ?? []); assertGrantRequestReply(record, message, priorMessages); + if (beforeCommit && !beforeCommit()) return undefined; const durableMessage = { ...message }; delete durableMessage.grantRequestResolved; delete durableMessage.replyToPreview; diff --git a/packages/core/src/channels/user-questions.ts b/packages/core/src/channels/user-questions.ts index e9f886e4ae..11d2e8bc98 100644 --- a/packages/core/src/channels/user-questions.ts +++ b/packages/core/src/channels/user-questions.ts @@ -11,6 +11,7 @@ import type { import { dmChannelId, type ChannelMessage } from './channel.js'; import type { ChannelStore } from './store.js'; import type { SessionOwnership } from '../sessions/ownership.js'; +import type { ToolApprovalActor } from '../workspaces/tool-approval.js'; export interface ChannelQuestionRequest { sessionId: string; @@ -21,6 +22,13 @@ export interface ChannelQuestionResolution { requestMessageId: string; state: 'answered' | 'cancelled'; answers?: AskUserQuestionAnswerItem[]; + actor?: ToolApprovalActor; +} + +export interface ChannelQuestionNotice { + botSlug: string; + messageId: string; + sessionId: string; } type Pending = { @@ -71,6 +79,9 @@ export class ChannelUserQuestions { readonly #warn: (message: string) => void; readonly #changed: (slug: string, count: number) => void; readonly #pending = new Map(); + readonly #listeners = new Set< + (notice: ChannelQuestionNotice, status: 'pending' | 'answered' | 'cancelled') => void + >(); constructor( channels: ChannelStore, @@ -137,6 +148,7 @@ export class ChannelUserQuestions { pending.committed = true; if (this.status(pending.botSlug, message.id) === 'pending') this.#publishAttention(pending.botSlug); + if (this.#pending.get(message.id) === pending) this.#notify(message.id, pending, 'pending'); } return await answer; } finally { @@ -154,6 +166,22 @@ export class ChannelUserQuestions { .map(([messageId]) => messageId); } + subscribe( + listener: (notice: ChannelQuestionNotice, status: 'pending' | 'answered' | 'cancelled') => void, + ): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + pending(botSlug: string, messageId: string): ChannelQuestionNotice | undefined { + const pending = this.#pending.get(messageId); + return pending?.committed && this.status(botSlug, messageId) === 'pending' + ? { botSlug, messageId, sessionId: pending.agent.session.id } + : undefined; + } + activeSessionIds(): string[] { return this.activeMessageIds().flatMap((id) => { const pending = this.#pending.get(id); @@ -181,9 +209,17 @@ export class ChannelUserQuestions { botSlug: string, messageId: string, answer: AskUserQuestionAnswer, + external?: { actor: ToolApprovalActor; authorized(): boolean }, ): Promise { const pending = this.#pending.get(messageId); - if (pending === undefined || pending.botSlug !== botSlug || pending.deciding) return false; + if ( + pending === undefined || + pending.botSlug !== botSlug || + pending.deciding || + !pending.committed + ) + return false; + if (external && !external.authorized()) return false; if (pending.signal?.aborted || !this.#isLive(pending.agent)) { this.#cancel(messageId); return false; @@ -194,6 +230,12 @@ export class ChannelUserQuestions { return false; } if (!validAnswer(pending.questions, answer)) return false; + if ( + this.#channels + .readMessages(pending.channelId) + .some((message) => message.userQuestionResolution?.requestMessageId === messageId) + ) + return false; pending.deciding = true; try { const decision: ChannelMessage = { @@ -206,15 +248,27 @@ export class ChannelUserQuestions { requestMessageId: messageId, state: 'answered', answers: answer.answers, + ...(external ? { actor: external.actor } : {}), }, }; - if ((await this.#channels.appendMessage(pending.channelId, decision)) === undefined) + if ( + (await this.#channels.appendMessage( + pending.channelId, + decision, + undefined, + () => + this.#pending.get(messageId) === pending && + this.status(botSlug, messageId) === 'pending' && + (!external || external.authorized()), + )) === undefined + ) return false; if (this.#pending.get(messageId) !== pending || pending.signal?.aborted) return false; this.#pending.delete(messageId); this.#publishAttention(pending.botSlug); pending.signal?.removeEventListener('abort', pending.abort); pending.resolve(answer); + this.#notify(messageId, pending, 'answered'); return true; } finally { pending.deciding = false; @@ -226,6 +280,29 @@ export class ChannelUserQuestions { if (pending.agent.session.id === sessionId) this.#cancel(id); } + reconcile(botSlug: string, messageId: string): boolean { + const pending = this.#pending.get(messageId); + if (!pending?.committed || pending.deciding || this.status(botSlug, messageId) !== 'pending') + return false; + const resolution = this.#channels + .readMessages(pending.channelId) + .find( + (message) => message.userQuestionResolution?.requestMessageId === messageId, + )?.userQuestionResolution; + if ( + resolution?.state !== 'answered' || + !resolution.answers || + !validAnswer(pending.questions, { answers: resolution.answers }) + ) + return false; + this.#pending.delete(messageId); + this.#publishAttention(botSlug); + pending.signal?.removeEventListener('abort', pending.abort); + pending.resolve({ answers: resolution.answers }); + this.#notify(messageId, pending, 'answered'); + return true; + } + #publishAttention(slug: string): void { const count = [...this.#pending.values()].filter( (pending) => pending.botSlug === slug && pending.committed, @@ -239,6 +316,20 @@ export class ChannelUserQuestions { close(): void { for (const id of this.#pending.keys()) this.#cancel(id); + this.#listeners.clear(); + } + + #notify(messageId: string, pending: Pending, status: 'pending' | 'answered' | 'cancelled'): void { + for (const listener of this.#listeners) { + try { + listener( + { botSlug: pending.botSlug, messageId, sessionId: pending.agent.session.id }, + status, + ); + } catch { + this.#warn('user-question-notification-failed'); + } + } } #cancel(messageId: string): void { @@ -260,6 +351,7 @@ export class ChannelUserQuestions { (saved) => { if (saved === undefined) this.#warn(`botharness.channel_question.cancel_append_failed request=${messageId}`); + else this.#notify(messageId, pending, 'cancelled'); }, () => this.#warn(`botharness.channel_question.cancel_append_failed request=${messageId}`), ); diff --git a/packages/core/src/database/schema-plan.ts b/packages/core/src/database/schema-plan.ts index b6790ffdf3..75e5ca717f 100644 --- a/packages/core/src/database/schema-plan.ts +++ b/packages/core/src/database/schema-plan.ts @@ -1594,6 +1594,20 @@ const APPROVAL_MESSAGING_MIGRATION: SchemaMigration = { }, }; +const QUESTION_MESSAGING_MIGRATION: SchemaMigration = { + generation: 61, + module: 'messaging', + description: 'Retain checked native question notification references and receipts', + migrate(database) { + database.exec(` + CREATE TABLE messaging_question_deliveries ( + id TEXT PRIMARY KEY, bot_slug TEXT NOT NULL, body TEXT NOT NULL CHECK(json_valid(body)) + ); + CREATE INDEX messaging_question_deliveries_bot ON messaging_question_deliveries(bot_slug); + `); + }, +}; + export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ SESSION_OWNERSHIP_MIGRATION, MESSAGING_TRACER_MIGRATION, @@ -1654,4 +1668,5 @@ export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ DISCORD_PLATFORM_DEFAULTS_MIGRATION, BOT_PAIRING_MIGRATION, APPROVAL_MESSAGING_MIGRATION, + QUESTION_MESSAGING_MIGRATION, ]); diff --git a/packages/core/src/messaging/approval-messaging.ts b/packages/core/src/messaging/approval-messaging.ts index 4923655199..c7380184c8 100644 --- a/packages/core/src/messaging/approval-messaging.ts +++ b/packages/core/src/messaging/approval-messaging.ts @@ -154,7 +154,7 @@ export function createApprovalMessaging(options: { paired.bindingId !== identity.id || paired.revision !== value.pairingRevision || paired.status !== 'approved' || - !paired.capabilities.some((c) => c === 'approve' || c === 'reject') + !paired.capabilities.some((c) => c === 'approve' || c === 'reject' || c === 'answer') ) throw new MessagingError('approval-route-unavailable'); const provider = options.provider(identity.providerId); @@ -405,6 +405,13 @@ export function createApprovalMessaging(options: { if (read(notice.messageId)) return; const selected = route(notice.botSlug); if (!selected) return; + const paired = pairing.list(notice.botSlug).find((value) => value.id === selected.pairingId); + if ( + !paired?.capabilities.some( + (capability) => capability === 'approve' || capability === 'reject', + ) + ) + return; write({ id: notice.messageId, botSlug: notice.botSlug, @@ -470,7 +477,7 @@ export function createApprovalMessaging(options: { .filter( (p) => p.status === 'approved' && - p.capabilities.some((c) => c === 'approve' || c === 'reject'), + p.capabilities.some((c) => c === 'approve' || c === 'reject' || c === 'answer'), ) .map((p) => { let ready = false; diff --git a/packages/core/src/messaging/dsh-im.ts b/packages/core/src/messaging/dsh-im.ts index 5bee521cb9..b95caeea3d 100644 --- a/packages/core/src/messaging/dsh-im.ts +++ b/packages/core/src/messaging/dsh-im.ts @@ -10,6 +10,7 @@ import type { MessagingHistoryPage, MessagingApprovalCard, MessagingApprovalAck, + MessagingQuestionCard, } from './provider.js'; import { MessagingError, MessagingProviderError, type MessagingProvider } from './provider.js'; @@ -22,6 +23,18 @@ interface DshImTarget { export interface DshImOutboundService { contractVersion: 1; + questionCardVersion?: 1; + questionCardChecked?( + botId: string, + route: MessagingReplyRoute | MessagingReceipt, + card: MessagingQuestionCard, + options: { + expectedFingerprint: string; + signal: AbortSignal; + beforeSend(): boolean; + update?: boolean; + }, + ): Promise<{ sent?: true; updated?: true; receipt?: MessagingReceipt }>; approvalCardVersion?: 1; approvalCardChecked?( botId: string, @@ -385,6 +398,55 @@ export function createDshImProvider( return { account: current, target }; }, ...(platform === 'feishu' && + host.questionCardVersion === 1 && + typeof host.questionCardChecked === 'function' + ? { + async questionCard(input: Parameters>[0]) { + input.signal.throwIfAborted(); + const info = await host.describeBot(input.accountRef); + if (info.account.fingerprint !== input.fingerprint) + throw new MessagingError('rebind-required'); + if ( + !info.connected || + ![ + 'question-card-checked', + 'question-card-update-checked', + 'question-action-consumer', + ].every((cap) => info.capabilities.includes(cap)) + ) + throw new MessagingProviderError('capability-unavailable', 'not-started'); + try { + const result = await host.questionCardChecked!( + input.accountRef, + input.route, + input.card, + { + expectedFingerprint: input.fingerprint, + signal: input.signal, + beforeSend: input.beforeSend, + ...(input.update ? { update: true } : {}), + }, + ); + if (input.update) { + if (result.updated !== true) + throw new MessagingProviderError('provider-result-unknown', 'unknown'); + return { updated: true as const }; + } + if ( + result.sent !== true || + result.receipt?.version !== 1 || + !result.receipt.messageId || + result.receipt.conversationId !== input.route.conversationId + ) + throw new MessagingProviderError('provider-result-unknown', 'unknown'); + return { sent: true as const, receipt: result.receipt }; + } catch (error) { + throw providerFailure(error); + } + }, + } + : {}), + ...(platform === 'feishu' && host.approvalCardVersion === 1 && typeof host.approvalCardChecked === 'function' ? { @@ -569,7 +631,19 @@ export function createDshImProvider( conversationId: identifier, messageId: identifier, requestId: z.string().uuid(), - action: z.enum(['allowed-once', 'rejected']), + action: z.enum(['allowed-once', 'rejected', 'answer']), + values: z + .array( + z + .object({ + selected: z.array(z.number().int().min(0).max(99)).max(100), + custom: z.string().min(1).max(2000).optional(), + }) + .strict(), + ) + .min(1) + .max(3) + .optional(), }) .strict() .parse(raw); @@ -579,7 +653,28 @@ export function createDshImProvider( event.fingerprint !== input.fingerprint ) throw new MessagingError('untrusted-source'); - return input.onAction!(event, context.signal); + if (event.action === 'answer') { + if ( + !event.values || + host.questionCardVersion !== 1 || + !info.capabilities.includes('question-action-consumer') + ) + throw new MessagingError('untrusted-source'); + return input.onAction!( + { + ...event, + action: 'answer', + values: event.values.map(({ selected, custom }) => ({ + selected, + ...(custom === undefined ? {} : { custom }), + })), + }, + context.signal, + ); + } + if (event.values !== undefined) throw new MessagingError('untrusted-source'); + const { values: _, ...approval } = event; + return input.onAction!({ ...approval, action: event.action }, context.signal); }, } : {}), diff --git a/packages/core/src/messaging/inbound.ts b/packages/core/src/messaging/inbound.ts index 40b8181490..031010df02 100644 --- a/packages/core/src/messaging/inbound.ts +++ b/packages/core/src/messaging/inbound.ts @@ -182,6 +182,16 @@ export interface InboundMessaging { } export function createInboundMessaging(options: { + onQuestionAction?( + providerId: string, + event: import('./provider.js').MessagingQuestionAction, + signal: AbortSignal, + ): Promise; + onQuestionText?( + providerId: string, + event: MessagingInboundEvent, + signal: AbortSignal, + ): Promise; onApprovalAction?( providerId: string, event: import('./provider.js').MessagingApprovalAction, @@ -1170,6 +1180,7 @@ export function createInboundMessaging(options: { event: MessagingInboundEvent, signal: AbortSignal, ) => { + if (await options.onQuestionText?.(provider.id, event, signal)) return true; if ( !options.pairing || provider.id !== 'dsh-im/feishu' || @@ -1263,8 +1274,13 @@ export function createInboundMessaging(options: { consume: fanoutMessagingConsumer( provider.consume.bind(provider), (event, signal) => controlIntake(provider, event, signal), - options.onApprovalAction - ? (event, signal) => options.onApprovalAction!(provider.id, event, signal) + options.onApprovalAction || options.onQuestionAction + ? (event, signal) => + event.action === 'answer' + ? (options.onQuestionAction?.(provider.id, event, signal) ?? + Promise.resolve({ accepted: true, status: 'refused' })) + : (options.onApprovalAction?.(provider.id, event, signal) ?? + Promise.resolve({ accepted: true, status: 'refused' })) : undefined, ), } diff --git a/packages/core/src/messaging/outbound.ts b/packages/core/src/messaging/outbound.ts index e1734c4fc7..df3996d0b9 100644 --- a/packages/core/src/messaging/outbound.ts +++ b/packages/core/src/messaging/outbound.ts @@ -3,6 +3,11 @@ import { type ApprovalMessaging, type ApprovalMessagingSnapshot, } from './approval-messaging.js'; +import { + createQuestionMessaging, + type QuestionDelivery, + type QuestionMessaging, +} from './question-messaging.js'; import { createBotPairing, type BotPairing, type PairingRequest } from './pairing.js'; import { assertMessagingIdentity, @@ -157,6 +162,7 @@ export interface OutboxIntent { } export interface MessagingSnapshot { + questions?: QuestionDelivery[]; approvals?: ApprovalMessagingSnapshot; setup?: { providerReady: boolean; @@ -190,6 +196,7 @@ export interface MessagingSnapshot { } export interface OutboundMessaging { + questions: QuestionMessaging; approvals: ApprovalMessaging; pairing: BotPairing; inbound: InboundMessaging; @@ -478,7 +485,18 @@ export function createOutboundMessaging(options: { isBotActive: options.isBotActive, ...(options.warn ? { warn: options.warn } : {}), }); + const questions = createQuestionMessaging({ + database, + pairing, + approvals, + provider: (id) => provider(id).provider, + isBotActive: options.isBotActive, + ...(options.recover === undefined ? {} : { recover: options.recover }), + ...(options.warn ? { warn: options.warn } : {}), + }); const inbound = createInboundMessaging({ + onQuestionAction: (providerId, event, signal) => questions.action(providerId, event, signal), + onQuestionText: (providerId, event, signal) => questions.text(providerId, event, signal), onApprovalAction: (providerId, event, signal) => approvals.action(providerId, event, signal), pairing, database, @@ -571,6 +589,7 @@ export function createOutboundMessaging(options: { }) : undefined; const service: OutboundMessaging = { + questions, approvals, inbound, pairing, @@ -1054,6 +1073,7 @@ export function createOutboundMessaging(options: { } providers.set(value.id, { provider: value, token }); const disposeInbound = inbound.register(value); + questions.refresh(); return () => { disposeInbound(); if (providers.get(value.id)?.token === token) providers.delete(value.id); @@ -1295,6 +1315,7 @@ export function createOutboundMessaging(options: { identities, pairings: pairing.list(botSlug), approvals: approvals.snapshot(botSlug), + questions: questions.snapshot(botSlug), pairingReceivers: identities .filter((i) => i.platform === 'feishu' && !i.revokedAt) .map((i) => ({ @@ -1817,6 +1838,7 @@ export function createOutboundMessaging(options: { close() { closed = true; approvals.close(); + questions.close(); inbound.close(); providers.clear(); for (const attempt of inFlight.values()) attempt.controller.abort(); diff --git a/packages/core/src/messaging/provider.ts b/packages/core/src/messaging/provider.ts index 6e6207b2ff..49a78a1b89 100644 --- a/packages/core/src/messaging/provider.ts +++ b/packages/core/src/messaging/provider.ts @@ -153,6 +153,29 @@ export interface MessagingApprovalAck { status: 'queued' | 'refused'; } +export interface MessagingQuestionCard { + requestId: string; + reference: string; + questions: import('@deepseek-ai/dsh-user-questions/types').AskUserQuestionItem[]; + status: 'pending' | 'answered' | 'cancelled' | 'expired' | 'web-required'; + detail: string; +} + +export interface MessagingQuestionAction { + version: 1; + channel: 'feishu'; + botId: string; + fingerprint: string; + actorId: string; + conversationId: string; + messageId: string; + requestId: string; + action: 'answer'; + values: { selected: number[]; custom?: string }[]; +} + +export type MessagingCardAction = MessagingApprovalAction | MessagingQuestionAction; + export interface MessagingProvider { id: string; accounts(): Promise; @@ -171,7 +194,7 @@ export interface MessagingProvider { signal: AbortSignal; onEvent(event: MessagingInboundEvent, signal: AbortSignal): Promise<{ accepted: true }>; onEcho?(event: MessagingOwnEcho, signal: AbortSignal): Promise<{ accepted: true }>; - onAction?(event: MessagingApprovalAction, signal: AbortSignal): Promise; + onAction?(event: MessagingCardAction, signal: AbortSignal): Promise; }): Promise<() => void>; approvalCard?(input: { accountRef: string; @@ -182,6 +205,15 @@ export interface MessagingProvider { beforeSend(): boolean; update?: boolean; }): Promise<{ sent?: true; updated?: true; receipt?: MessagingReceipt }>; + questionCard?(input: { + accountRef: string; + fingerprint: string; + route: MessagingReplyRoute | MessagingReceipt; + card: MessagingQuestionCard; + signal: AbortSignal; + beforeSend(): boolean; + update?: boolean; + }): Promise<{ sent?: true; updated?: true; receipt?: MessagingReceipt }>; qualifyReply?(input: { accountRef: string; fingerprint: string; diff --git a/packages/core/src/messaging/question-messaging.ts b/packages/core/src/messaging/question-messaging.ts new file mode 100644 index 0000000000..7dedb1b29a --- /dev/null +++ b/packages/core/src/messaging/question-messaging.ts @@ -0,0 +1,596 @@ +import { createHash } from 'node:crypto'; +import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types'; +import { dmChannelId } from '../channels/channel.js'; +import type { ChannelStore } from '../channels/store.js'; +import type { ChannelQuestionNotice, ChannelUserQuestions } from '../channels/user-questions.js'; +import type { OperationalDatabaseModulePort } from '../database/owner.js'; +import type { ToolApprovalActor } from '../workspaces/tool-approval.js'; +import type { ApprovalMessaging, ApprovalRoute } from './approval-messaging.js'; +import { assertMessagingIdentity } from './identity.js'; +import type { BotPairing } from './pairing.js'; +import { + MessagingError, + MessagingProviderError, + type MessagingInboundEvent, + type MessagingProvider, + type MessagingQuestionAction, + type MessagingQuestionCard, + type MessagingReceipt, +} from './provider.js'; + +export interface QuestionDelivery { + id: string; + botSlug: string; + sessionId: string; + reference: string; + questionHash: string; + route: ApprovalRoute; + createdAt: string; + status: 'pending' | 'answered' | 'cancelled' | 'expired'; + delivery: 'pending' | 'sending' | 'sent' | 'failed' | 'unknown-outcome'; + attempts: number; + receipt?: MessagingReceipt; + submission: 'none' | 'submitting' | 'accepted' | 'refused' | 'unknown-outcome'; + update: 'none' | 'sending' | 'updated' | 'failed' | 'unknown-outcome'; + reason?: string; +} + +export interface QuestionMessaging { + attach(owner: ChannelUserQuestions, channels: ChannelStore): () => void; + refresh(): void; + snapshot(botSlug: string): QuestionDelivery[]; + retry(botSlug: string, id: string): Promise; + action( + providerId: string, + event: MessagingQuestionAction, + signal: AbortSignal, + ): Promise<{ accepted: true; status: 'queued' | 'refused' }>; + text(providerId: string, event: MessagingInboundEvent, signal: AbortSignal): Promise; + close(): void; +} + +export function createQuestionMessaging(options: { + database: OperationalDatabaseModulePort; + pairing: BotPairing; + approvals: ApprovalMessaging; + provider(id: string): MessagingProvider; + isBotActive(slug: string): boolean; + recover?: boolean; + warn?(message: string): void; +}): QuestionMessaging { + const { database, pairing } = options; + let owner: ChannelUserQuestions | undefined; + let channels: ChannelStore | undefined; + let detach: (() => void) | undefined; + let closed = false; + const controller = new AbortController(); + const busy = new Set(); + const rows = (slug: string): QuestionDelivery[] => + database.read((db) => + ( + db + .prepare( + 'SELECT body FROM messaging_question_deliveries WHERE bot_slug = ? ORDER BY rowid DESC LIMIT 50', + ) + .all(slug) as { body: string }[] + ).map((row) => JSON.parse(row.body) as QuestionDelivery), + ); + const read = (id: string): QuestionDelivery | undefined => + closed + ? undefined + : database.read((db) => { + const row = db + .prepare('SELECT body FROM messaging_question_deliveries WHERE id = ?') + .get(id) as { body: string } | undefined; + return row ? (JSON.parse(row.body) as QuestionDelivery) : undefined; + }); + const write = (value: QuestionDelivery) => { + if (closed) return; + database.transaction( + (db) => + db + .prepare( + 'INSERT INTO messaging_question_deliveries(id, bot_slug, body) VALUES(?, ?, ?) ON CONFLICT(id) DO UPDATE SET body = excluded.body', + ) + .run(value.id, value.botSlug, JSON.stringify(value)), + ['question-messaging'], + ); + }; + const request = (value: QuestionDelivery) => { + const question = channels?.message(dmChannelId(value.botSlug), value.id)?.userQuestionRequest; + return question?.sessionId === value.sessionId && + createHash('sha256').update(JSON.stringify(question.questions)).digest('hex') === + value.questionHash + ? question + : undefined; + }; + const resolution = (value: QuestionDelivery) => + channels + ?.readMessages(dmChannelId(value.botSlug)) + .find((message) => message.userQuestionResolution?.requestMessageId === value.id) + ?.userQuestionResolution; + const context = (value: QuestionDelivery) => { + if (closed || !options.isBotActive(value.botSlug)) throw new MessagingError('bot-unavailable'); + const selected = options.approvals.snapshot(value.botSlug).route; + if ( + !selected || + selected.revision !== value.route.revision || + selected.pairingId !== value.route.pairingId + ) + throw new MessagingError('approval-route-stale'); + const identity = database.read((db) => assertMessagingIdentity(db, value.route.bindingId)); + if (identity.botSlug !== value.botSlug || identity.platform !== 'feishu') + throw new MessagingError('untrusted-source'); + const paired = pairing.list(value.botSlug).find((item) => item.id === value.route.pairingId); + if ( + !paired || + paired.status !== 'approved' || + paired.bindingId !== identity.id || + paired.revision !== value.route.pairingRevision || + !paired.capabilities.includes('answer') + ) + throw new MessagingError('question-answer-unavailable'); + const provider = options.provider(identity.providerId); + if (!provider.questionCard) throw new MessagingError('capability-unavailable'); + return { identity, paired, provider }; + }; + const safe = (value: QuestionDelivery) => { + try { + context(value); + return !!request(value); + } catch { + return false; + } + }; + const live = (value: QuestionDelivery) => + !!owner?.pending(value.botSlug, value.id) && !!request(value); + const state = (value: QuestionDelivery): QuestionDelivery['status'] => + resolution(value)?.state ?? + (value.status === 'pending' && !live(value) ? 'expired' : value.status); + const render = (value: QuestionDelivery): MessagingQuestionCard => { + const status = state(value); + const answers = resolution(value)?.answers; + const detail = answers + ? answers.map((answer) => answer.custom ?? answer.selected.join(', ')).join('\n') + : value.submission === 'unknown-outcome' + ? 'Answer not confirmed; inspect and reconcile in Web / 回答结果未确认,请在 Web 核对' + : value.submission === 'refused' + ? 'Answer not accepted; the original question still waits / 回答未被接受,原提问继续等待' + : value.submission === 'submitting' + ? 'Checking the native question owner / 正在核验原生提问' + : `Choose answers or enter your own, then Submit / 选择或填写答案后提交。\nFor a single question you may reply to this card, or send /answer ${value.reference} your answer. / 单个问题也可回复此卡片,或使用 /answer ${value.reference} 答案。`; + return { + requestId: value.id, + reference: value.reference, + questions: request(value)?.questions ?? [], + status: value.submission === 'unknown-outcome' && live(value) ? 'web-required' : status, + detail, + }; + }; + const trace = (phase: string, value: QuestionDelivery, startedAt: number) => + options.warn?.( + `question-notification ${JSON.stringify({ + initiator: 'native-question', + phase, + outcome: value.delivery, + submission: value.submission, + update: value.update, + durationMs: Math.round(performance.now() - startedAt), + reason: value.reason ?? 'none', + })}`, + ); + const bounded = (run: (signal: AbortSignal) => Promise): Promise => { + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]); + return Promise.race([ + run(signal), + new Promise((_, reject) => + signal.addEventListener( + 'abort', + () => reject(new MessagingProviderError('question-result-unknown', 'unknown')), + { once: true }, + ), + ), + ]); + }; + const update = async (id: string): Promise => { + const value = read(id); + if (!value?.receipt || busy.has(id) || !safe(value)) return; + const startedAt = performance.now(); + const card = render(value); + busy.add(id); + write({ ...value, status: state(value), update: 'sending' }); + try { + const { identity, provider } = context(value); + const result = await bounded((signal) => + provider.questionCard!({ + accountRef: identity.accountRef, + fingerprint: identity.fingerprint, + route: value.receipt!, + card, + signal, + update: true, + beforeSend: () => safe(value) && render(read(id)!).status === card.status, + }), + ); + if (result.updated !== true) + throw new MessagingProviderError('provider-result-unknown', 'unknown'); + const latest = read(id); + if (latest) write({ ...latest, update: 'updated' }); + } catch (error) { + const latest = read(id); + if (latest) + write({ + ...latest, + update: + error instanceof MessagingProviderError && error.disposition === 'not-started' + ? 'failed' + : 'unknown-outcome', + }); + } finally { + busy.delete(id); + const latest = read(id); + if (latest) { + trace('update', latest, startedAt); + if (render(latest).status !== card.status) void update(id); + } + } + }; + const send = async (id: string): Promise => { + const value = read(id); + if ( + !value || + busy.has(id) || + value.attempts >= 3 || + !['pending', 'failed'].includes(value.delivery) + ) + return; + const startedAt = performance.now(); + busy.add(id); + let started = false; + write({ ...value, attempts: value.attempts + 1 }); + try { + const { identity, paired, provider } = context(value); + if (!live(value) || !paired.messageIds?.[0]) + throw new MessagingProviderError('question-owner-unavailable', 'not-started'); + write({ ...value, attempts: value.attempts + 1, delivery: 'sending' }); + started = true; + const result = await bounded((signal) => + provider.questionCard!({ + accountRef: identity.accountRef, + fingerprint: identity.fingerprint, + route: { + messageId: paired.messageIds![0]!, + actorId: paired.actorId, + conversationId: paired.conversationId, + }, + card: render(value), + signal, + beforeSend: () => safe(value) && live(value), + }), + ); + if ( + result.sent !== true || + result.receipt?.version !== 1 || + result.receipt.conversationId !== paired.conversationId + ) + throw new MessagingProviderError('provider-result-unknown', 'unknown'); + const latest = read(id); + if (latest) write({ ...latest, delivery: 'sent', receipt: result.receipt }); + } catch (error) { + const latest = read(id); + if (latest) + write({ + ...latest, + delivery: + !started || + (error instanceof MessagingProviderError && error.disposition === 'not-started') + ? 'failed' + : 'unknown-outcome', + reason: + error instanceof MessagingError || error instanceof MessagingProviderError + ? error.code + : 'provider-result-unknown', + }); + } finally { + busy.delete(id); + const latest = read(id); + if (latest) { + trace('send', latest, startedAt); + if (latest.receipt && state(latest) !== 'pending') void update(id); + } + } + }; + const notify = (notice: ChannelQuestionNotice, status: 'pending' | 'answered' | 'cancelled') => { + if (closed) return; + if (status === 'pending') { + if (read(notice.messageId)) return; + const route = options.approvals.snapshot(notice.botSlug).route; + const question = channels?.message( + dmChannelId(notice.botSlug), + notice.messageId, + )?.userQuestionRequest; + if (!route || !question) return; + write({ + id: notice.messageId, + botSlug: notice.botSlug, + sessionId: notice.sessionId, + reference: notice.messageId.replaceAll('-', '').slice(0, 12).toUpperCase(), + questionHash: createHash('sha256').update(JSON.stringify(question.questions)).digest('hex'), + route, + createdAt: new Date().toISOString(), + status: 'pending', + delivery: 'pending', + attempts: 0, + submission: 'none', + update: 'none', + }); + void send(notice.messageId).catch(() => options.warn?.('question-notification-send-failed')); + } else { + const value = read(notice.messageId); + if (!value) return; + write({ + ...value, + status, + submission: status === 'answered' ? 'accepted' : value.submission, + }); + void update(value.id); + } + }; + const authorized = ( + value: QuestionDelivery, + providerId: string, + event: { botId: string; fingerprint: string; actorId: string; conversationId: string }, + ) => { + try { + const { identity, paired } = context(value); + const current = pairing.assert(value.botSlug, identity.id, event.actorId, 'answer'); + return ( + safe(value) && + live(value) && + identity.providerId === providerId && + identity.accountRef === event.botId && + identity.fingerprint === event.fingerprint && + paired.id === current.id && + current.revision === value.route.pairingRevision && + paired.conversationId === event.conversationId + ); + } catch { + return false; + } + }; + const submit = ( + value: QuestionDelivery, + answer: AskUserQuestionAnswer, + providerId: string, + event: { + botId: string; + fingerprint: string; + actorId: string; + conversationId: string; + messageId: string; + }, + signal: AbortSignal, + ) => { + if ( + signal.aborted || + value.status !== 'pending' || + ['submitting', 'unknown-outcome'].includes(value.submission) || + value.delivery !== 'sent' || + !authorized(value, providerId, event) + ) + return false; + const actor: ToolApprovalActor = { + platform: 'feishu', + bindingId: value.route.bindingId, + fingerprint: event.fingerprint, + actorId: event.actorId, + pairingId: value.route.pairingId, + pairingRevision: value.route.pairingRevision, + conversationId: event.conversationId, + messageId: event.messageId, + }; + write({ ...value, submission: 'submitting' }); + queueMicrotask(() => { + void owner + ?.answer(value.botSlug, value.id, answer, { + actor, + authorized: () => !signal.aborted && authorized(value, providerId, event), + }) + .then((accepted) => { + const latest = read(value.id); + if (latest) + write({ + ...latest, + status: state(latest), + submission: accepted ? 'accepted' : 'refused', + }); + void update(value.id); + }) + .catch(() => { + const latest = read(value.id); + if (latest) + write({ + ...latest, + submission: 'unknown-outcome', + reason: 'question-answer-unconfirmed', + }); + void update(value.id); + }); + }); + return true; + }; + if (options.recover !== false) + database.transaction((db) => + db + .prepare( + "UPDATE messaging_question_deliveries SET body = json_set(body, '$.status', CASE WHEN json_extract(body, '$.status') = 'pending' THEN 'expired' ELSE json_extract(body, '$.status') END, '$.delivery', CASE WHEN json_extract(body, '$.delivery') = 'sending' THEN 'unknown-outcome' ELSE json_extract(body, '$.delivery') END, '$.submission', CASE WHEN json_extract(body, '$.submission') = 'submitting' THEN 'unknown-outcome' ELSE json_extract(body, '$.submission') END, '$.update', CASE WHEN json_extract(body, '$.status') = 'pending' AND json_extract(body, '$.receipt') IS NOT NULL THEN 'none' WHEN json_extract(body, '$.update') = 'sending' THEN 'unknown-outcome' ELSE json_extract(body, '$.update') END)", + ) + .run(), + ); + const service: QuestionMessaging = { + attach(nextOwner, nextChannels) { + detach?.(); + owner = nextOwner; + channels = nextChannels; + const unsubscribe = owner.subscribe(notify); + detach = unsubscribe; + service.refresh(); + return () => { + unsubscribe(); + if (detach === unsubscribe) { + owner = undefined; + channels = undefined; + detach = undefined; + } + }; + }, + refresh() { + if (closed || !channels) return; + const pendingUpdates = database.read((db) => + ( + db + .prepare( + "SELECT body FROM messaging_question_deliveries WHERE json_extract(body, '$.receipt') IS NOT NULL AND json_extract(body, '$.status') != 'pending' AND json_extract(body, '$.update') IN ('none', 'failed') ORDER BY rowid DESC LIMIT 50", + ) + .all() as { body: string }[] + ).map((row) => JSON.parse(row.body) as QuestionDelivery), + ); + for (const value of pendingUpdates) void update(value.id); + }, + snapshot(botSlug) { + return closed ? [] : rows(botSlug).map((value) => ({ ...value, status: state(value) })); + }, + async retry(botSlug, id) { + const value = read(id); + if (!value || value.botSlug !== botSlug || !safe(value)) + throw new MessagingError('question-repair-unavailable'); + if (value.submission === 'unknown-outcome') { + if (owner?.reconcile(botSlug, id)) return; + if (!live(value) || resolution(value)) + throw new MessagingError('question-owner-unavailable'); + write({ ...value, submission: 'refused', reason: 'original-question-still-pending' }); + await update(id); + } else if (value.delivery === 'failed' && live(value) && value.attempts < 3) await send(id); + else if (value.receipt && value.update === 'failed') await update(id); + else throw new MessagingError('question-repair-unavailable'); + }, + async action(providerId, event, signal) { + const value = read(event.requestId); + const question = value && request(value); + if ( + !value || + !question || + value.receipt?.messageId !== event.messageId || + value.receipt.conversationId !== event.conversationId || + event.values.length !== question.questions.length || + event.values.some((answer, index) => + answer.selected.some( + (pick) => + !Number.isSafeInteger(pick) || + pick < 0 || + pick >= (question.questions[index]!.options?.length ?? 0), + ), + ) + ) + return { accepted: true, status: 'refused' }; + const answers = event.values.map((answer, index) => ({ + id: question.questions[index]!.id, + selected: answer.selected.map((pick) => question.questions[index]!.options![pick]!.label), + ...(answer.custom === undefined ? {} : { custom: answer.custom }), + })); + return { + accepted: true, + status: submit(value, { answers }, providerId, event, signal) ? 'queued' : 'refused', + }; + }, + async text(providerId, event, signal) { + if (closed) return false; + const command = /^\/answer(?:\s|$)/.test(event.text.trim()); + const candidates = database.read((db) => + ( + db + .prepare( + "SELECT body FROM messaging_question_deliveries WHERE json_extract(body, '$.status') = 'pending' AND json_extract(body, '$.receipt.conversationId') = ? ORDER BY rowid DESC LIMIT 100", + ) + .all(event.conversation.id) as { body: string }[] + ).map((row) => JSON.parse(row.body) as QuestionDelivery), + ); + const quoted = candidates.find((value) => value.receipt?.messageId === event.reply.parentId); + if (!command && !quoted) return false; + if ( + event.channel !== 'feishu' || + event.conversation.kind !== 'dm' || + event.attachments?.length + ) + return true; + const match = /^\/answer\s+([A-Fa-f0-9]{12})\s+([\s\S]+)$/.exec(event.text.trim()); + const value = + quoted ?? + (match ? candidates.find((item) => item.reference === match[1]!.toUpperCase()) : undefined); + const ambiguous = + command && + (!match || (quoted !== undefined && quoted.reference !== match[1]!.toUpperCase())); + const trusted = candidates.find((item) => + authorized(item, providerId, { + ...event, + actorId: event.actor.id, + conversationId: event.conversation.id, + }), + ); + if (!trusted || signal.aborted) return true; + const question = value && request(value); + const text = match ? match[2]! : event.text.trim(); + if ( + !ambiguous && + value && + question?.questions.length === 1 && + text.length > 0 && + text.length <= 2000 + ) { + submit( + value, + { answers: [{ id: question.questions[0]!.id, selected: [], custom: text }] }, + providerId, + { ...event, actorId: event.actor.id, conversationId: event.conversation.id }, + signal, + ); + } else { + const { identity, provider } = context(trusted); + await provider.reply?.({ + accountRef: identity.accountRef, + fingerprint: identity.fingerprint, + route: event.reply, + text: + 'Select the original question form, or use /answer for one question. / 请使用原提问表单;单个问题可用 /answer <编号> <答案>。\n' + + candidates + .filter((item) => + authorized(item, providerId, { + ...event, + actorId: event.actor.id, + conversationId: event.conversation.id, + }), + ) + .map((item) => item.reference) + .join('\n'), + signal, + beforeSend: () => + authorized(trusted, providerId, { + ...event, + actorId: event.actor.id, + conversationId: event.conversation.id, + }), + }); + } + return true; + }, + close() { + closed = true; + controller.abort(); + detach?.(); + busy.clear(); + owner = undefined; + channels = undefined; + }, + }; + return service; +} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 6e0396f44e..ed5c2fb0cf 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -819,6 +819,11 @@ export function apply(ctx: Context, config: BotHarnessConfig): void { (slug, count) => core.states.setQuestionCount(slug, count), ); ctx.effect(() => () => userQuestions.close(), 'botharness: Channel user questions'); + if (core.operationalDatabase.mode === 'ready') + ctx.effect( + () => core.externalMessaging.questions.attach(userQuestions, core.channels), + 'botharness: checked IM question answers', + ); ctx.on( 'user-questions/request', async (request, next) => (await userQuestions.ask(request)) ?? next(), diff --git a/packages/core/test/bridge-rpc.test.ts b/packages/core/test/bridge-rpc.test.ts index e6161aeb97..b745faafc5 100644 --- a/packages/core/test/bridge-rpc.test.ts +++ b/packages/core/test/bridge-rpc.test.ts @@ -1,5 +1,5 @@ import { createTestRosterStore } from './roster-fixture.js'; -import { createTestRegistry } from './registry-fixture.js'; +import { createTestRegistry, registryDatabase } from './registry-fixture.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -24,7 +24,10 @@ import { createTestOwnership } from './helpers.js'; const roots: string[] = []; afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const root of roots.splice(0)) { + registryDatabase(root).close(); + rmSync(root, { recursive: true, force: true }); + } }); function setup(sourcePolicy?: BotSourcePolicyStore) { @@ -40,7 +43,7 @@ function setup(sourcePolicy?: BotSourcePolicyStore) { states: createBotStateTracker(), channels, ownership: createTestOwnership(), - roster: createTestRosterStore(), + roster: createTestRosterStore({ database: registryDatabase(root) }), createBotId: () => 'ada', ...(sourcePolicy === undefined ? {} : { sourcePolicy }), }); @@ -88,6 +91,7 @@ describe('bridge typert service', () => { 'approvalRoute', 'approvalTest', 'approvalRetry', + 'questionRetry', 'pairingReview', 'messagingSnapshot', 'messagingTargets', diff --git a/packages/core/test/lark-questions.test.ts b/packages/core/test/lark-questions.test.ts new file mode 100644 index 0000000000..2a57e9f107 --- /dev/null +++ b/packages/core/test/lark-questions.test.ts @@ -0,0 +1,402 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import type { Agent } from '@deepseek-ai/dsh-agent'; +import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'; +import { createCore, type BotHarnessCore } from '../src/plugin.js'; +import { ChannelUserQuestions } from '../src/channels/user-questions.js'; +import { createDshImProvider, type DshImOutboundService } from '../src/messaging/dsh-im.js'; +import { + MessagingProviderError, + type MessagingQuestionAction, + type MessagingInboundEvent, +} from '../src/messaging/provider.js'; +import { createTempRoot } from './helpers.js'; +import { createQuestionMessaging } from '../src/messaging/question-messaging.js'; +import { attachOperationalModule } from '../src/database/owner.js'; +import { createBridgeMethods } from '../src/bridge/methods.js'; + +const fingerprint = 'a'.repeat(64); +const cores: BotHarnessCore[] = []; +const owners: ChannelUserQuestions[] = []; +afterEach(async () => { + for (const owner of owners.splice(0)) owner.close(); + for (const core of cores.splice(0)) { + core.externalMessaging.close(); + await core.runtime.close(); + core.operationalDatabase.close(); + } +}); +async function fixture() { + const core = createCore({ dshHome: createTempRoot('bh-lark-question-') }); + cores.push(core); + core.registry.create({ slug: 'ada', displayName: 'Ada' }); + core.channels.getOrCreateDm('ada', 'Ada'); + let receiver: Parameters>[1] | undefined; + let fail: 'none' | 'known' | 'unknown' = 'none'; + const cards = vi.fn>( + async (_bot, route, card, input) => { + expect(input.beforeSend?.()).toBe(true); + if (fail !== 'none') + throw new MessagingProviderError('simulated', fail === 'known' ? 'not-started' : 'unknown'); + return input.update + ? { updated: true } + : { + sent: true, + receipt: { + version: 1, + messageId: 'om_' + card.requestId, + conversationId: route.conversationId, + }, + }; + }, + ); + const replies = vi.fn>( + async (_bot, _route, _text, input) => { + expect(input.beforeSend?.()).toBe(true); + return { sent: true }; + }, + ); + const host: DshImOutboundService = { + contractVersion: 1, + approvalCardVersion: 1, + questionCardVersion: 1, + replyFenceVersion: 1, + listBots: async () => [{ botId: 'lark-qa', channel: 'feishu' }], + listTargets: async () => [], + describeBot: async (botId) => ({ + version: 1, + botId, + channel: 'feishu', + connected: true, + account: { fingerprint, name: 'QA Lark' }, + capabilities: [ + 'proactive-text-checked', + 'exclusive-text-consumer', + 'reply-text-checked', + 'approval-card-checked', + 'approval-card-update-checked', + 'approval-action-consumer', + 'question-card-checked', + 'question-card-update-checked', + 'question-action-consumer', + ], + }), + sendChecked: async () => ({ sent: true }), + replyChecked: replies, + approvalCardChecked: async () => ({ sent: true }), + questionCardChecked: cards, + consumeInbound: async (_bot, input) => { + receiver = input; + return () => {}; + }, + }; + core.externalMessaging.register(createDshImProvider(host)!); + await core.externalMessaging.identity('ada', { + kind: 'bind', + providerId: 'dsh-im/feishu', + accountRef: 'lark-qa', + fingerprint, + }); + await vi.waitFor(() => expect(receiver).toBeDefined()); + const event: MessagingInboundEvent = { + version: 1, + channel: 'feishu', + botId: 'lark-qa', + fingerprint, + eventId: 'pair', + messageId: 'om_pair', + actor: { kind: 'user', id: 'ou_alice', name: 'Alice QA' }, + conversation: { kind: 'dm', id: 'oc_private' }, + mentions: [], + mentionedAccount: false, + at: new Date().toISOString(), + text: '/pair', + reply: { messageId: 'om_pair', conversationId: 'oc_private', actorId: 'ou_alice' }, + replay: { kind: 'provider-redelivery', resumeCursor: false, gapPossible: true }, + }; + await receiver!.onEvent(event, { signal: receiver!.signal }); + const pairing = core.externalMessaging.pairing.list('ada')[0]!; + const approved = core.externalMessaging.pairing.review('ada', { + id: pairing.id, + expectedRevision: 1, + kind: 'approve', + capabilities: ['answer'], + }); + await core.externalMessaging.approvals.setRoute('ada', pairing.id, 0); + const sessionId = 'bh-question-qa'; + core.ownership.claim({ + sessionId, + botSlug: 'ada', + rootRole: 'orchestrator', + at: new Date().toISOString(), + }); + const agent = { session: { id: sessionId, header: { cwd: process.cwd() } } } as Agent; + const owner = new ChannelUserQuestions(core.channels, core.ownership); + owners.push(owner); + core.externalMessaging.questions.attach(owner, core.channels); + const management = createBridgeMethods({ + registry: core.registry, + states: core.states, + channels: core.channels, + ownership: core.ownership, + roster: core.roster, + externalMessaging: core.externalMessaging, + }); + const start = async ( + questions: AskUserQuestionItem[] = [ + { + id: 'choice', + question: 'Choose a branch', + options: [ + { label: 'main', description: 'Current' }, + { label: 'qa', description: 'Test' }, + ], + }, + ], + ) => { + const previous = new Set( + core.externalMessaging.questions.snapshot('ada').map((value) => value.id), + ); + const answer = owner.ask({ agent, questions }); + void answer.catch(() => undefined); + await vi.waitFor(() => + expect( + core.externalMessaging.questions + .snapshot('ada') + .some((d) => !previous.has(d.id) && d.delivery !== 'pending' && d.delivery !== 'sending'), + ).toBe(true), + ); + const delivery = core.externalMessaging.questions.snapshot('ada')[0]!; + await vi.waitFor(() => + expect( + core.externalMessaging.questions.snapshot('ada').find((d) => d.id === delivery.id) + ?.delivery, + ).not.toMatch(/pending|sending/), + ); + return { answer, delivery }; + }; + const action = (id: string, changes: Partial = {}) => + receiver!.onAction!( + { + version: 1, + channel: 'feishu', + botId: 'lark-qa', + fingerprint, + actorId: 'ou_alice', + conversationId: 'oc_private', + messageId: 'om_' + id, + requestId: id, + action: 'answer', + values: [{ selected: [0] }], + ...changes, + }, + { signal: receiver!.signal }, + ); + const text = (text: string, parentId?: string) => { + const messageId = crypto.randomUUID(); + return receiver!.onEvent( + { + ...event, + eventId: crypto.randomUUID(), + messageId, + text, + reply: { ...event.reply, messageId, ...(parentId ? { parentId } : {}) }, + }, + { signal: receiver!.signal }, + ); + }; + return { + core, + owner, + cards, + replies, + approved, + start, + action, + text, + host, + agent, + management, + failure(value: typeof fail) { + fail = value; + }, + }; +} + +it('answer-only pairing settles the original native question, with actual actor and one canonical answer', async () => { + const f = await fixture(), + pending = await f.start([ + { + id: 'choice', + question: 'Choose a branch', + options: [{ label: 'main', description: 'Current' }], + }, + { id: 'notes', question: 'What should I remember?' }, + ]); + expect( + await f.action(pending.delivery.id, { + values: [{ selected: [0] }, { selected: [], custom: 'Remember this' }], + }), + ).toMatchObject({ status: 'queued' }); + expect(await pending.answer).toEqual({ + answers: [ + { id: 'choice', selected: ['main'] }, + { id: 'notes', selected: [], custom: 'Remember this' }, + ], + }); + const resolutions = f.core.channels + .readMessages('dm-ada') + .filter((m) => m.userQuestionResolution); + expect(resolutions).toHaveLength(1); + expect(resolutions[0]?.userQuestionResolution?.actor).toMatchObject({ + actorId: 'ou_alice', + pairingId: f.approved.id, + }); + expect(await f.action(pending.delivery.id)).toMatchObject({ status: 'refused' }); + expect(f.core.channels.readMessages('dm-ada').some((m) => m.toolApprovalDecision)).toBe(false); + await vi.waitFor(() => expect(f.cards.mock.calls.at(-1)?.[2].status).toBe('answered')); +}); + +it('forged actor, receipt, shape and revoked answer authority preserve the original ask', async () => { + const f = await fixture(), + pending = await f.start(); + for (const changed of [ + { actorId: 'ou_other' }, + { messageId: 'om_other' }, + { conversationId: 'oc_other' }, + ]) + expect(await f.action(pending.delivery.id, changed)).toMatchObject({ status: 'refused' }); + await expect(f.action(pending.delivery.id, { values: [] })).rejects.toThrow(); + expect(await f.action(pending.delivery.id, { values: [{ selected: [99] }] })).toMatchObject({ + status: 'refused', + }); + expect(f.owner.pending('ada', pending.delivery.id)).toBeDefined(); + f.core.externalMessaging.pairing.review('ada', { + kind: 'revoke', + id: f.approved.id, + expectedRevision: 2, + }); + expect(await f.action(pending.delivery.id)).toMatchObject({ status: 'refused' }); + expect(f.owner.pending('ada', pending.delivery.id)).toBeDefined(); +}); + +it('Web and Lark answer races have exactly one winner', async () => { + const f = await fixture(), + pending = await f.start(); + const web = f.owner.answer('ada', pending.delivery.id, { + answers: [{ id: 'choice', selected: ['qa'] }], + }); + await f.action(pending.delivery.id); + expect(await web).toBe(true); + expect(await pending.answer).toEqual({ answers: [{ id: 'choice', selected: ['qa'] }] }); + expect( + f.core.channels.readMessages('dm-ada').filter((m) => m.userQuestionResolution), + ).toHaveLength(1); +}); + +it('known-unsent delivery can be repaired, while uncertain delivery cannot be resent', async () => { + const f = await fixture(); + f.failure('known'); + const first = await f.start(); + expect(first.delivery.delivery).toBe('failed'); + f.failure('none'); + await f.core.externalMessaging.questions.retry('ada', first.delivery.id); + expect(f.core.externalMessaging.questions.snapshot('ada')[0]?.delivery).toBe('sent'); + f.failure('unknown'); + const second = await f.start(); + expect(second.delivery.delivery).toBe('unknown-outcome'); + const count = f.cards.mock.calls.length; + await expect(f.core.externalMessaging.questions.retry('ada', second.delivery.id)).rejects.toThrow( + 'question-repair-unavailable', + ); + expect(f.cards).toHaveBeenCalledTimes(count); +}); + +it('explicit references disambiguate questions; ordinary conversation and mismatched replies never answer', async () => { + const f = await fixture(), + first = await f.start(), + second = await f.start(); + await f.text('ordinary clarification'); + expect(f.owner.pending('ada', first.delivery.id)).toBeDefined(); + await f.text('/answer'); + expect(f.replies.mock.calls.at(-1)?.[2]).toContain(first.delivery.reference); + await f.text(`/answer ${second.delivery.reference} wrong question`, 'om_' + first.delivery.id); + expect(f.owner.pending('ada', first.delivery.id)).toBeDefined(); + await f.text(`/answer ${first.delivery.reference} custom branch`); + expect(await first.answer).toEqual({ + answers: [{ id: 'choice', selected: [], custom: 'custom branch' }], + }); + expect(f.owner.pending('ada', second.delivery.id)).toBeDefined(); +}); + +it('uncertain submission reconciles the original owner without replaying the answer', async () => { + const f = await fixture(), + pending = await f.start(); + const append = f.core.channels.appendMessage; + vi.spyOn(f.core.channels, 'appendMessage').mockImplementationOnce(async (...args) => { + await append(...args); + throw new Error('Result lost after commit'); + }); + await f.action(pending.delivery.id); + await vi.waitFor(() => + expect(f.core.externalMessaging.questions.snapshot('ada')[0]?.submission).toBe( + 'unknown-outcome', + ), + ); + expect(f.owner.pending('ada', pending.delivery.id)).toBeDefined(); + expect(await f.action(pending.delivery.id)).toMatchObject({ status: 'refused' }); + expect( + await f.management.questionRetry({ slug: 'other-bot', id: pending.delivery.id }), + ).toMatchObject({ ok: false, error: { code: 'question-repair-unavailable' } }); + expect(await f.management.questionRetry({ slug: 'ada', id: 'not-a-question' })).toMatchObject({ + ok: false, + }); + expect(await f.management.questionRetry({ slug: 'ada', id: pending.delivery.id })).toEqual({ + ok: true, + value: { updated: true }, + }); + expect(await pending.answer).toEqual({ answers: [{ id: 'choice', selected: ['main'] }] }); + expect( + f.core.channels.readMessages('dm-ada').filter((m) => m.userQuestionResolution), + ).toHaveLength(1); +}); + +it('a restarted notification service expires old controls and updates the original receipt without restoring the native owner', async () => { + const f = await fixture(), + pending = await f.start(); + f.core.externalMessaging.questions.close(); + const nextOwner = new ChannelUserQuestions(f.core.channels, f.core.ownership); + owners.push(nextOwner); + const restarted = createQuestionMessaging({ + database: attachOperationalModule(f.core.operationalDatabase, 'question-recovery-test'), + pairing: f.core.externalMessaging.pairing, + approvals: f.core.externalMessaging.approvals, + provider: () => createDshImProvider(f.host)!, + isBotActive: () => true, + }); + try { + restarted.attach(nextOwner, f.core.channels); + expect(restarted.snapshot('ada')[0]?.status).toBe('expired'); + expect(nextOwner.pending('ada', pending.delivery.id)).toBeUndefined(); + expect( + await restarted.action( + 'dsh-im/feishu', + { + version: 1, + channel: 'feishu', + botId: 'lark-qa', + fingerprint, + actorId: 'ou_alice', + conversationId: 'oc_private', + messageId: 'om_' + pending.delivery.id, + requestId: pending.delivery.id, + action: 'answer', + values: [{ selected: [0] }], + }, + new AbortController().signal, + ), + ).toMatchObject({ status: 'refused' }); + await vi.waitFor(() => expect(f.cards.mock.calls.at(-1)?.[2].status).toBe('expired')); + } finally { + restarted.close(); + } +}); diff --git a/packages/core/test/operational-database.test.ts b/packages/core/test/operational-database.test.ts index ca5a4e7a4f..02513ce67e 100644 --- a/packages/core/test/operational-database.test.ts +++ b/packages/core/test/operational-database.test.ts @@ -61,6 +61,51 @@ function faultAt( } describe('operational database owner', () => { + it('adds question delivery projection after approval routes without rewriting existing authority', () => { + const dshHome = createTempRoot('botharness-question-migration-'); + const priorPlan = defineSchemaPlan( + BOT_HARNESS_SCHEMA_PLAN.migrations.filter((migration) => migration.generation <= 60), + ); + const prior = mountOperationalDatabase({ dshHome, schemaPlan: priorPlan }); + attachOperationalModule(prior, 'question-migration-seed').transaction((database) => { + database + .prepare('INSERT INTO messaging_approval_routes(bot_slug, revision, body) VALUES(?, ?, ?)') + .run('ada', 2, '{"preserved":true}'); + database + .prepare( + 'INSERT INTO messaging_default_revisions(platform, revision, body) VALUES(?, ?, ?)', + ) + .run('discord', 1, '{"preserved":true}'); + }); + prior.close(); + const upgraded = mountOperationalDatabase({ dshHome, schemaPlan: BOT_HARNESS_SCHEMA_PLAN }); + try { + expect(upgraded.generation).toBe(61); + const module = attachOperationalModule(upgraded, 'question-migration-check'); + expect( + module.read((database) => + database.prepare('SELECT * FROM messaging_question_deliveries').all(), + ), + ).toEqual([]); + expect( + module.read((database) => + database + .prepare('SELECT revision, body FROM messaging_approval_routes WHERE bot_slug = ?') + .get('ada'), + ), + ).toEqual({ revision: 2, body: '{"preserved":true}' }); + expect( + module.read((database) => + database + .prepare('SELECT body FROM messaging_default_revisions WHERE platform = ?') + .get('discord'), + ), + ).toEqual({ body: '{"preserved":true}' }); + } finally { + upgraded.close(); + } + }); + it('adds pairing after current Discord defaults without rewriting their immutable history', () => { const dshHome = createTempRoot('botharness-pairing-migration-'); const priorPlan = defineSchemaPlan( diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index f830432298..96031e77f8 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -291,6 +291,7 @@ describe('plugin entry', () => { expect(core?.registry).toBeDefined(); expect(core?.memory).toBeDefined(); expect(core?.channels).toBeDefined(); + expect(() => core?.externalMessaging.questions.refresh()).not.toThrow(); expect(() => core?.ownership.claim({ sessionId: 'session-1', @@ -348,6 +349,7 @@ describe('plugin entry', () => { 'approvalRoute', 'approvalTest', 'approvalRetry', + 'questionRetry', 'pairingReview', 'messagingSnapshot', 'messagingTargets', diff --git a/packages/core/test/user-questions.test.ts b/packages/core/test/user-questions.test.ts index ccde887643..fe5d1eb10c 100644 --- a/packages/core/test/user-questions.test.ts +++ b/packages/core/test/user-questions.test.ts @@ -54,6 +54,105 @@ function fixture(role: 'orchestrator' | 'assignment' = 'orchestrator') { } describe('native DSH questions in a PersonaBot DM', () => { + it('publishes only committed requests and accepted answers, while a broken observer cannot settle the owner', async () => { + const state = fixture(); + const notices: string[] = []; + state.answerer.subscribe((notice, status) => { + expect(state.channels.message(channelId, notice.messageId)).toBeDefined(); + notices.push(status); + }); + state.answerer.subscribe(() => { + throw new Error('observer failed'); + }); + const wait = state.answerer.ask({ agent: state.agent, questions }); + await vi.waitFor(() => expect(notices).toEqual(['pending'])); + const id = state.answerer.activeMessageIds()[0]!; + expect(state.answerer.pending('ada', id)?.sessionId).toBe(sessionId); + expect( + await state.answerer.answer('ada', id, { + answers: [{ id: questions[0]!.id, selected: ['main'] }], + }), + ).toBe(true); + await wait; + expect(notices).toEqual(['pending', 'answered']); + expect(state.warn).toHaveBeenCalledWith('user-question-notification-failed'); + }); + + it('rechecks external authority inside the canonical Channel commit queue and preserves the ask when it is revoked', async () => { + const state = fixture(); + const wait = state.answerer.ask({ agent: state.agent, questions }); + await vi.waitFor(() => expect(state.answerer.activeMessageIds()).toHaveLength(1)); + const id = state.answerer.activeMessageIds()[0]!; + let authorized = true; + await vi.waitFor(() => expect(state.answerer.pending('ada', id)).toBeDefined()); + const answer = { answers: [{ id: questions[0]!.id, selected: ['main'] }] }; + const result = state.answerer.answer('ada', id, answer, { + actor: { + platform: 'feishu', + bindingId: 'binding', + fingerprint: 'a'.repeat(64), + actorId: 'ou_human', + pairingId: 'pairing', + pairingRevision: 1, + conversationId: 'oc_dm', + messageId: 'om_card', + }, + authorized: () => authorized, + }); + authorized = false; + expect(await result).toBe(false); + expect(state.channels.readMessages(channelId)).toHaveLength(1); + expect(state.answerer.status('ada', id)).toBe('pending'); + expect(await state.answerer.answer('ada', id, answer)).toBe(true); + expect(await wait).toEqual(answer); + }); + + it('keeps the original native ask after a failed answer write and accepts only one winner', async () => { + const state = fixture(); + const wait = state.answerer.ask({ agent: state.agent, questions }); + await vi.waitFor(() => expect(state.answerer.activeMessageIds()).toHaveLength(1)); + const id = state.answerer.activeMessageIds()[0]!; + const answer = { answers: [{ id: questions[0]!.id, selected: ['main'] }] }; + await vi.waitFor(() => expect(state.answerer.pending('ada', id)).toBeDefined()); + vi.spyOn(state.channels, 'appendMessage').mockRejectedValueOnce( + new Error('answer write failed'), + ); + await expect(state.answerer.answer('ada', id, answer)).rejects.toThrow('answer write failed'); + expect(state.answerer.status('ada', id)).toBe('pending'); + expect( + await Promise.all([ + state.answerer.answer('ada', id, answer), + state.answerer.answer('ada', id, answer), + ]), + ).toEqual([true, false]); + expect(await wait).toEqual(answer); + expect( + state.channels.readMessages(channelId).filter((m) => m.userQuestionResolution), + ).toHaveLength(1); + }); + + it('requires explicit owner reconciliation after a write commits but reports an uncertain result', async () => { + const state = fixture(); + const wait = state.answerer.ask({ agent: state.agent, questions }); + await vi.waitFor(() => expect(state.answerer.activeMessageIds()).toHaveLength(1)); + const id = state.answerer.activeMessageIds()[0]!; + await vi.waitFor(() => expect(state.answerer.pending('ada', id)).toBeDefined()); + const answer = { answers: [{ id: questions[0]!.id, selected: ['main'] }] }; + const append = state.channels.appendMessage.bind(state.channels); + vi.spyOn(state.channels, 'appendMessage').mockImplementationOnce(async (...args) => { + await append(...args); + throw new Error('post-commit write outcome unknown'); + }); + await expect(state.answerer.answer('ada', id, answer)).rejects.toThrow('unknown'); + expect(await state.answerer.answer('ada', id, answer)).toBe(false); + expect(state.answerer.pending('ada', id)).toBeDefined(); + expect(state.answerer.reconcile('ada', id)).toBe(true); + expect(await wait).toEqual(answer); + expect( + state.channels.readMessages(channelId).filter((m) => m.userQuestionResolution), + ).toHaveLength(1); + }); + it('commits a card, validates the Human answer, and resumes the same waiting request', async () => { const state = fixture(); const wait = state.answerer.ask({ agent: state.agent, questions });