mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: ask_user — structured questions from the agent with web card, channel buttons, and text answers (#109922)
* feat(gateway): add transient question runtime (question.* methods + broadcasts) * feat(agents): add blocking ask_user question tool with chat prompt delivery and text-reply claim * feat(ui): interactive in-thread question cards for ask_user * feat(channels): native tap-to-answer buttons for ask_user on Telegram, Discord, and Slack * feat(ui): unify codex and gateway question cards with interactive gateway answering * refactor(agents): collapse ask_user pending state to one registry; docs for ask_user * fix(agents): include ask_user in normal gateway runs; add question-flow control-ui e2e * test(ui): avoid credential-shaped fixture in question card test * refactor(ui): reorder stream-group context keys * fix(gateway,ui): validate question answers at resolve; reject secret/duplicate-label questions; UI retry and reconnect hardening * fix(gateway,agents): canonicalize accepted option answers; bound ask_user option labels to 64 chars * chore(ci): prune unused question exports, allowlist mobile question events, fix discord lint * chore(ci): regenerate protocol/i18n/docs/tool-display artifacts for question surface * fix(protocol): flatten QuestionRecord for native codegen; drop TS-only alias from schema registry * chore(android): regenerate ask-user localization resources * docs: regenerate docs map after rebase * fix(ci): avoid stale read-only dependency disks * test: remove stale reef lint suppression ratchet * fix(ci): keep source locale drift advisory in release gates * fix(ci): scope locale advisory handling to parity check
This commit is contained in:
committed by
GitHub
parent
bab9a5ede7
commit
da44d52ac6
@@ -345,6 +345,31 @@ runs:
|
||||
;;
|
||||
esac
|
||||
|
||||
sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint"
|
||||
sticky_fingerprint=""
|
||||
sticky_snapshot_matches="false"
|
||||
if [ "$STICKY_DISK" = "true" ] && [ -f "$sticky_marker" ]; then
|
||||
sticky_fingerprint="$(<"$sticky_marker")"
|
||||
fi
|
||||
if [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ] &&
|
||||
[ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]; then
|
||||
sticky_snapshot_matches="true"
|
||||
fi
|
||||
if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" != "true" ] &&
|
||||
[ "$sticky_snapshot_matches" != "true" ]; then
|
||||
# Read-only PR clones cannot refresh a stale snapshot. Installing into
|
||||
# that clone can saturate its ext4 device until short jobs time out.
|
||||
# Detach only the workspace bind; the action still discards its clone.
|
||||
sudo umount "$GITHUB_WORKSPACE/node_modules"
|
||||
rm -rf "$GITHUB_WORKSPACE/node_modules"
|
||||
mkdir -p "$GITHUB_WORKSPACE/node_modules"
|
||||
ephemeral_store="${RUNNER_TEMP:?}/openclaw-pnpm-store"
|
||||
mkdir -p "$ephemeral_store"
|
||||
export PNPM_CONFIG_STORE_DIR="$ephemeral_store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$ephemeral_store" >> "$GITHUB_ENV"
|
||||
echo "Sticky dependency snapshot is stale; using runner-local storage for this read-only run"
|
||||
fi
|
||||
|
||||
install_args=(
|
||||
install
|
||||
--prefer-offline
|
||||
@@ -374,13 +399,7 @@ runs:
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}"
|
||||
fi
|
||||
sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint"
|
||||
sticky_fingerprint=""
|
||||
if [ "$STICKY_DISK" = "true" ] && [ -f "$sticky_marker" ]; then
|
||||
sticky_fingerprint="$(<"$sticky_marker")"
|
||||
fi
|
||||
if [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ] &&
|
||||
[ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]; then
|
||||
if [ "$sticky_snapshot_matches" = "true" ]; then
|
||||
bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE"
|
||||
echo "Sticky dependency snapshot matches the install fingerprint; skipping pnpm install"
|
||||
else
|
||||
|
||||
@@ -1301,9 +1301,6 @@ jobs:
|
||||
name: control-ui-i18n
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||||
# Source PR drift stays advisory because the post-merge bot owns repair.
|
||||
# Generated locale PRs and release CI must pass the strict catalog gate.
|
||||
continue-on-error: ${{ github.event_name != 'workflow_dispatch' && needs.preflight.outputs.strict_control_ui_i18n != 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -1320,6 +1317,9 @@ jobs:
|
||||
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
||||
|
||||
- name: Check Control UI locale parity
|
||||
# Source-only drift stays advisory because the post-merge bot owns
|
||||
# repair. Generated locale changes and full release CI remain strict.
|
||||
continue-on-error: ${{ needs.preflight.outputs.strict_control_ui_i18n != 'true' }}
|
||||
run: pnpm ui:i18n:check
|
||||
|
||||
checks-fast-core:
|
||||
|
||||
@@ -129,6 +129,11 @@ enum class GatewayMethod(
|
||||
ExecApprovalRequest("exec.approval.request"),
|
||||
ExecApprovalWaitDecision("exec.approval.waitDecision"),
|
||||
ExecApprovalResolve("exec.approval.resolve"),
|
||||
QuestionRequest("question.request"),
|
||||
QuestionWaitAnswer("question.waitAnswer"),
|
||||
QuestionResolve("question.resolve"),
|
||||
QuestionGet("question.get"),
|
||||
QuestionList("question.list"),
|
||||
PluginApprovalList("plugin.approval.list"),
|
||||
PluginApprovalRequest("plugin.approval.request"),
|
||||
PluginApprovalWaitDecision("plugin.approval.waitDecision"),
|
||||
@@ -423,6 +428,8 @@ enum class GatewayEvent(
|
||||
VoicewakeRoutingChanged("voicewake.routing.changed"),
|
||||
ExecApprovalRequested("exec.approval.requested"),
|
||||
ExecApprovalResolved("exec.approval.resolved"),
|
||||
QuestionRequested("question.requested"),
|
||||
QuestionResolved("question.resolved"),
|
||||
PluginApprovalRequested("plugin.approval.requested"),
|
||||
PluginApprovalResolved("plugin.approval.resolved"),
|
||||
OpenclawApprovalRequested("openclaw.approval.requested"),
|
||||
|
||||
@@ -177,6 +177,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
|
||||
"Arguments" to R.string.native_7f816072c1c6a23b,
|
||||
"Ask OpenClaw anything" to R.string.native_8998824610d49175,
|
||||
"Ask OpenClaw to use Android capabilities." to R.string.native_e2d1ec2328a8146f,
|
||||
"Ask User" to R.string.native_9309a242422af6f3,
|
||||
"Assistant speech muted" to R.string.native_a8bbbd7231a6c191,
|
||||
"Assistant working" to R.string.native_e02b4aac2b335f6f,
|
||||
"Attach" to R.string.native_d406ade2958cb5b6,
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"كل %1$s يوم"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مفعّل"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"متصل وجاهز"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطأ في الدردشة"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s من %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطّط للعمل"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Alle %1$s Tage"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktiviert"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online und bereit"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chatfehler"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s von %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Arbeit planen"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cada %1$s d"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activado"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En línea y listo"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Error de chat"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s de %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planifica el trabajo"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"هر %1$s روز"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فعال"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"آنلاین و آماده"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خطای چت"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s از %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برنامهریزی کار"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tous les %1$s j"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activé"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En ligne et prêt"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erreur de chat"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s sur %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planifier le travail"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"हर %1$s दिन"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सक्षम"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ऑनलाइन और तैयार"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चैट त्रुटि"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s में से %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"काम की योजना बनाएँ"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Setiap %1$s hari"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Diaktifkan"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online dan siap"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kesalahan chat"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s dari %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rencanakan pekerjaan"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ogni %1$s g"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abilitato"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online e pronto"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Errore chat"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s di %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pianifica il lavoro"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s日ごと"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"有効"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"オンラインで準備完了"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"チャット エラー"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s / %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"作業を計画"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s일마다"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"활성화됨"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"온라인 및 준비됨"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"채팅 오류"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s / %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"작업 계획 세우기"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Elke %1$s d"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ingeschakeld"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online en gereed"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chatfout"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s van %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Plan het werk"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Co %1$s d"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Włączone"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online i gotowy"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Błąd czatu"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s z %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zaplanuj pracę"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"A cada %1$sd"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ativado"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online e pronto"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Erro no chat"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s de %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planeje o trabalho"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Каждые %1$s дн."</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Включено"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"В сети и готово"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ошибка чата"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s из %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Спланировать работу"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Var %1$s dag"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktiverad"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online och redo"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chattfel"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s av %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planera arbetet"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ทุก %1$s วัน"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปิดใช้งานแล้ว"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ออนไลน์และพร้อมใช้งาน"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้อผิดพลาดของแชท"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s จาก %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"วางแผนงาน"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Her %1$s günde bir"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Etkin"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Çevrimiçi ve hazır"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sohbet hatası"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s / %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Çalışmayı planlayın"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Кожні %1$s дн."</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Увімкнено"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"У мережі й готово"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Помилка чату"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s з %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Спланувати роботу"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mỗi %1$s ngày"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã bật"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang trực tuyến và sẵn sàng"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lỗi trò chuyện"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s trên %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lập kế hoạch công việc"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"每 %1$s 天"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已启用"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在线且已就绪"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"聊天错误"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s / %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"规划工作"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"每 %1$s 天"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已啟用"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已上線並準備就緒"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"聊天錯誤"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s / %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"規劃工作"</string>
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<string name="native_92b8699dada676f9" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Every %1$sd"</string>
|
||||
<string name="native_92c1cdfdf4cb9cf6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Enabled"</string>
|
||||
<string name="native_92d01b120cacf65f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Online and ready"</string>
|
||||
<string name="native_9309a242422af6f3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ask User"</string>
|
||||
<string name="native_938ee8367299dc55" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chat error"</string>
|
||||
<string name="native_93bf9fb90d28b037" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s of %2$s"</string>
|
||||
<string name="native_93c59f1918d177c6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Plan the work"</string>
|
||||
|
||||
@@ -440,6 +440,13 @@
|
||||
"plan.0.step"
|
||||
]
|
||||
},
|
||||
"ask_user": {
|
||||
"emoji": "❓",
|
||||
"title": "Ask User",
|
||||
"detailKeys": [
|
||||
"questions.0.question"
|
||||
]
|
||||
},
|
||||
"spawn_task": {
|
||||
"emoji": "✨",
|
||||
"title": "Suggest Task",
|
||||
|
||||
@@ -178,6 +178,13 @@ public enum ApprovalTerminalReason: String, Codable, Sendable {
|
||||
case storageCorrupt = "storage-corrupt"
|
||||
}
|
||||
|
||||
public enum QuestionStatus: String, Codable, Sendable {
|
||||
case pending = "pending"
|
||||
case answered = "answered"
|
||||
case cancelled = "cancelled"
|
||||
case expired = "expired"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
@@ -12616,6 +12623,270 @@ public struct ExecApprovalResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionOption: Codable, Sendable {
|
||||
public let label: String
|
||||
public let description: String?
|
||||
|
||||
public init(
|
||||
label: String,
|
||||
description: String? = nil)
|
||||
{
|
||||
self.label = label
|
||||
self.description = description
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case label
|
||||
case description
|
||||
}
|
||||
}
|
||||
|
||||
public struct Question: Codable, Sendable {
|
||||
public let id: String
|
||||
public let header: String
|
||||
public let question: String
|
||||
public let options: [QuestionOption]
|
||||
public let multiselect: Bool?
|
||||
public let isother: Bool?
|
||||
public let issecret: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
header: String,
|
||||
question: String,
|
||||
options: [QuestionOption],
|
||||
multiselect: Bool? = nil,
|
||||
isother: Bool? = nil,
|
||||
issecret: Bool? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.header = header
|
||||
self.question = question
|
||||
self.options = options
|
||||
self.multiselect = multiselect
|
||||
self.isother = isother
|
||||
self.issecret = issecret
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case header
|
||||
case question
|
||||
case options
|
||||
case multiselect = "multiSelect"
|
||||
case isother = "isOther"
|
||||
case issecret = "isSecret"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionRequestQuestion: Codable, Sendable {
|
||||
public let id: String
|
||||
public let header: String
|
||||
public let question: String
|
||||
public let options: [QuestionOption]
|
||||
public let multiselect: Bool?
|
||||
public let isother: Bool?
|
||||
public let issecret: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
header: String,
|
||||
question: String,
|
||||
options: [QuestionOption],
|
||||
multiselect: Bool? = nil,
|
||||
isother: Bool? = nil,
|
||||
issecret: Bool? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.header = header
|
||||
self.question = question
|
||||
self.options = options
|
||||
self.multiselect = multiselect
|
||||
self.isother = isother
|
||||
self.issecret = issecret
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case header
|
||||
case question
|
||||
case options
|
||||
case multiselect = "multiSelect"
|
||||
case isother = "isOther"
|
||||
case issecret = "isSecret"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionAnswers: Codable, Sendable {
|
||||
public let answers: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
answers: [String: AnyCodable])
|
||||
{
|
||||
self.answers = answers
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case answers
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionRecord: Codable, Sendable {
|
||||
public let id: String
|
||||
public let questions: [Question]
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let createdatms: Int
|
||||
public let expiresatms: Int
|
||||
public let status: QuestionStatus
|
||||
public let answers: QuestionAnswers?
|
||||
public let resolvedby: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
questions: [Question],
|
||||
agentid: String? = nil,
|
||||
sessionkey: String? = nil,
|
||||
createdatms: Int,
|
||||
expiresatms: Int,
|
||||
status: QuestionStatus,
|
||||
answers: QuestionAnswers? = nil,
|
||||
resolvedby: String? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.questions = questions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.createdatms = createdatms
|
||||
self.expiresatms = expiresatms
|
||||
self.status = status
|
||||
self.answers = answers
|
||||
self.resolvedby = resolvedby
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case questions
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case createdatms = "createdAtMs"
|
||||
case expiresatms = "expiresAtMs"
|
||||
case status
|
||||
case answers
|
||||
case resolvedby = "resolvedBy"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let questions: [QuestionRequestQuestion]
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String? = nil,
|
||||
questions: [QuestionRequestQuestion],
|
||||
agentid: String? = nil,
|
||||
sessionkey: String? = nil,
|
||||
timeoutms: Int? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.questions = questions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case questions
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionRequestResult: Codable, Sendable {
|
||||
public let id: String
|
||||
public let expiresatms: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
expiresatms: Int)
|
||||
{
|
||||
self.id = id
|
||||
self.expiresatms = expiresatms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case expiresatms = "expiresAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionWaitAnswerParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
timeoutms: Int? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionGetParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
id: String)
|
||||
{
|
||||
self.id = id
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionGetResult: Codable, Sendable {
|
||||
public let question: QuestionRecord
|
||||
|
||||
public init(
|
||||
question: QuestionRecord)
|
||||
{
|
||||
self.question = question
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case question
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionListParams: Codable, Sendable {}
|
||||
|
||||
public struct QuestionListResult: Codable, Sendable {
|
||||
public let questions: [QuestionRecord]
|
||||
|
||||
public init(
|
||||
questions: [QuestionRecord])
|
||||
{
|
||||
self.questions = questions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case questions
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let pluginid: String?
|
||||
public let title: String
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f08ff5ef3bead5b08631b72d347884808c262aab7d33c8ae085407ba55e44402 plugin-sdk-api-baseline.json
|
||||
d62e193e943766c515978531ef88463eba94094bd77eecef397cef391d841a67 plugin-sdk-api-baseline.jsonl
|
||||
7e717544b401b5ca3447d5383e241f678b6c343c089b3f430c2fc48244f1b3c8 plugin-sdk-api-baseline.json
|
||||
0b4bc1cfc71177b5f5e0d146c48d86d48ff0eea1a075358adb7583ee35af54b9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ The slowest Node test families are split or balanced so each job stays small wit
|
||||
- The full Node matrix admits the consistently slow serial tooling, auto-reply command shards, and broad core-fast cache writer first. This keeps the 28-job cap while preventing critical-path work and the next run's transform seed from slipping into a later wave.
|
||||
- Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard.
|
||||
- Linux Node shard jobs persist Vitest's experimental filesystem module cache. Trusted Blacksmith jobs clone one protected disk per platform and Node line; pull requests write only to their private clone and discard it, so PR traffic cannot allocate backing disks or publish feature-branch transforms. GitHub-hosted and fork jobs use an `actions/cache` fallback with coarse PR-scoped restore prefixes. The planner marks the broad `core-unit-fast` graph as the single writer without coupling cache ownership to matrix order. Concurrent Vitest workers retain separate live directories. A transform-input fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations inside the stable disk. Only a protected writer scans and prunes the cache to 75% after it exceeds 2 GiB. A non-cancelling daily or default-branch repository-dispatch warmer refreshes the protected seed; GitHub's normal cache eviction expires fallback PR archives.
|
||||
- Trusted Linux Node jobs also bind the pnpm store and `node_modules` from one protected dependency disk per supported Node line. Package manifests, install settings, runner platform, and the exact Node patch stay out of the disk key; an exact runtime and install-input fingerprint decides whether a job reuses the tree or reinstalls and refreshes the same disk. After that exact restore or frozen-lockfile install, setup disables pnpm's redundant pre-run dependency check: the repository intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and repairs through unsafe concurrent implicit installs during shard fanout. The non-cancelled cache warmer is the only writer: successful `main` CI completion coalesces a dependency refresh, while the daily run remains a deadline fallback. Required CI jobs and pull requests get disposable clones, so dependency changes do not create new disks, competing snapshots, or a cache lock that can cancel builds.
|
||||
- Trusted Linux Node jobs also bind the pnpm store and `node_modules` from one protected dependency disk per supported Node line. Package manifests, install settings, runner platform, and the exact Node patch stay out of the disk key; an exact runtime and install-input fingerprint decides whether a job reuses the tree or reinstalls and refreshes the same disk. A pull request whose read-only snapshot has a different fingerprint detaches the workspace bind and installs into runner-local storage, avoiding slow writes to a clone it cannot publish. After an exact restore or frozen-lockfile install, setup disables pnpm's redundant pre-run dependency check: the repository intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and repairs through unsafe concurrent implicit installs during shard fanout. The non-cancelled cache warmer is the only writer: successful `main` CI completion coalesces a dependency refresh, while the daily run remains a deadline fallback. Required CI jobs and pull requests get disposable clones, so dependency changes do not create new disks, competing snapshots, or a cache lock that can cancel builds.
|
||||
- Node shard and build-artifact jobs also restore Node's portable on-disk compile cache. Independent `test` and `build` namespaces prevent their writers from replacing each other's snapshots: the scheduled test warmer owns the protected test seed, while `build-artifacts` publishes the protected build seed only from trusted `main` pushes. PR jobs read protected snapshots without publishing feature-branch bytecode; fallback archives remain PR-scoped. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. Vitest child processes disable an inherited compile cache because coverage can be enabled inside dynamic configs and V8 coverage can lose source-position precision when scripts are deserialized from bytecode.
|
||||
- The build-artifact job also persists content-fingerprinted `build-all` step outputs. CI's self-built plugin SDK declarations hash the complete repository-owned TypeScript/JSON source graph, exclude installed and generated directories, and restore both flat declarations and package bridges after `tsdown` clears `dist`. Documentation, workflow, plugin, and other changes outside that graph can reuse the declaration snapshot; source changes rebuild it before the export gate runs.
|
||||
- Full declaration builds split `tsdown` into AI, workspace-package, and unified groups. Each group caches declarations only, then still rebuilds runtime JavaScript before restoring those declarations. Core or plugin changes therefore invalidate only the large unified graph, while workspace-package changes conservatively invalidate every dependent declaration group. Public full builds generally use an immutable Actions cache; coarse restore keys seed partial changes, per-group content fingerprints reject stale data, and GitHub's cache quota evicts old generations. The weekly Node 22 lane instead publishes a 14-day artifact after successful `main` runs and restores only artifacts whose immutable producer identity resolves to that workflow on `main`, avoiding quota churn without allowing PR code to write a shared cache. Private-QA declarations are never persisted in Actions caches because cache namespaces are not confidentiality boundaries.
|
||||
|
||||
@@ -1358,6 +1358,7 @@
|
||||
"group": "Tools",
|
||||
"pages": [
|
||||
"tools/apply-patch",
|
||||
"tools/ask-user",
|
||||
"tools/btw",
|
||||
"tools/code-execution",
|
||||
"tools/code-mode",
|
||||
|
||||
@@ -9493,6 +9493,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Example
|
||||
- H2: Related
|
||||
|
||||
## tools/ask-user.md
|
||||
|
||||
- Route: /tools/ask-user
|
||||
- Headings:
|
||||
- H2: Answer a question
|
||||
- H2: Timeout and no answer
|
||||
- H2: Tool schema
|
||||
- H2: Model guidance
|
||||
|
||||
## tools/brave-search.md
|
||||
|
||||
- Route: /tools/brave-search
|
||||
|
||||
@@ -77,6 +77,11 @@ type MessagePresentationAction =
|
||||
approvalKind: "exec" | "plugin";
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
}
|
||||
| {
|
||||
type: "question";
|
||||
questionId: string;
|
||||
optionValue: string;
|
||||
}
|
||||
| { type: "url"; url: string }
|
||||
| {
|
||||
type: "web-app";
|
||||
@@ -136,6 +141,12 @@ Button semantics:
|
||||
encode that action into a transport-private callback and resolve it through
|
||||
the approval service; they must not parse `/approve` command text or infer
|
||||
kind from the ID.
|
||||
- `action.type: "question"` identifies one choice for a live, runtime-authored
|
||||
`ask_user` question. Like `approval`, this is an OpenClaw runtime action;
|
||||
agents and plugins must not synthesize question IDs. Telegram, Discord, and
|
||||
Slack map it to transport-private native callbacks and resolve the choice
|
||||
through the Gateway. Other channels degrade the controls to label text, and
|
||||
the user can answer with a plain-text reply.
|
||||
- `action.type: "url"` opens a normal link.
|
||||
- `action.type: "web-app"` launches a channel-native web app. Set `url` for a
|
||||
URL-backed app or `widgetId` for an OpenClaw-hosted widget whose launch
|
||||
|
||||
@@ -136,6 +136,7 @@ deprecated for new code; see the per-row notes below.
|
||||
| `plugin-sdk/telegram-account` | Deprecated Telegram account-resolution compatibility facade for tracked owner compatibility; new plugins should use injected runtime helpers or generic channel SDK subpaths |
|
||||
| `plugin-sdk/zalouser` | Deprecated Zalo Personal compatibility facade for published Lark/Zalo packages that still import sender command authorization; new plugins should use generic channel SDK subpaths |
|
||||
| `plugin-sdk/interactive-runtime` | Semantic message presentation, delivery, and legacy interactive reply helpers. See [Message Presentation](/plugins/message-presentation) |
|
||||
| `plugin-sdk/question-gateway-runtime` | Resolve runtime-authored `ask_user` choices through the Gateway from channel interaction handlers |
|
||||
| `plugin-sdk/channel-inbound` | Shared inbound helpers for event classification, context building, formatting, roots, debounce, mention matching, mention-policy, and inbound logging |
|
||||
| `plugin-sdk/channel-inbound-debounce` | Narrow inbound debounce helpers |
|
||||
| `plugin-sdk/channel-mention-gating` | Narrow mention-policy, mention marker, and mention text helpers without the broader inbound runtime surface |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
summary: "How ask_user pauses an agent turn for a structured human decision"
|
||||
read_when:
|
||||
- You want an agent to ask the user a structured question
|
||||
- You are answering or debugging an ask_user prompt
|
||||
- You need the ask_user schema, timeout, or channel behavior
|
||||
title: "Ask user"
|
||||
---
|
||||
|
||||
`ask_user` lets the agent ask the human one to three structured questions and
|
||||
wait for the answers. It is for decisions that genuinely belong to the user,
|
||||
not routine confirmation or information the agent can derive from the request,
|
||||
code, or a sensible default.
|
||||
|
||||
The tool is available only in the main session. Subagents and other non-primary
|
||||
runs do not receive it.
|
||||
|
||||
## Answer a question
|
||||
|
||||
You can answer from any supported conversation surface:
|
||||
|
||||
- The web Control UI shows one unified question card.
|
||||
- Telegram, Discord, and Slack render native buttons for a single-choice,
|
||||
single-question prompt.
|
||||
- A plain-text reply works on any channel. Reply with a number, an option label,
|
||||
or your own answer.
|
||||
|
||||
OpenClaw always enables a free-text **Other** answer. The agent must not add an
|
||||
`Other` option to the authored option list.
|
||||
|
||||
Prompts that cannot use native buttons, including multi-question and
|
||||
multi-select prompts, degrade to readable text. The Control UI keeps the full
|
||||
structured card.
|
||||
|
||||
## Timeout and no answer
|
||||
|
||||
The default timeout is 900 seconds. `timeoutSeconds` is clamped to the range
|
||||
30 through 3600 seconds.
|
||||
|
||||
If the question expires or is cancelled before an answer arrives, the tool
|
||||
returns `status: "no_answer"`. The agent then continues with its best judgment.
|
||||
An aborted agent run cancels its pending Gateway question.
|
||||
|
||||
## Tool schema
|
||||
|
||||
```ts
|
||||
{
|
||||
questions: Array<{
|
||||
id: string; // unique snake_case answer key
|
||||
header: string; // short label; truncated to 12 characters
|
||||
question: string; // one sentence
|
||||
options: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>; // 2-4 options
|
||||
multiSelect?: boolean;
|
||||
}>; // 1-3 questions
|
||||
timeoutSeconds?: number; // integer; default 900, clamped to 30-3600
|
||||
}
|
||||
```
|
||||
|
||||
With `multiSelect: true`, the user can choose more than one option. Answer
|
||||
values are returned as an array for every question.
|
||||
|
||||
Example answered result:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "answered",
|
||||
"answers": {
|
||||
"answers": {
|
||||
"deploy_target": {
|
||||
"answers": ["Staging (Recommended)"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Model guidance
|
||||
|
||||
The model-facing contract tells the agent to:
|
||||
|
||||
- ask only when blocked on a genuinely user-owned decision;
|
||||
- prefer one question and use no more than three;
|
||||
- put the recommended option first and suffix its label with `(Recommended)`;
|
||||
- omit an authored `Other` option because free text is added automatically;
|
||||
- continue with best judgment after `no_answer`.
|
||||
|
||||
The agent should not use `ask_user` to ask whether it may proceed or to confirm
|
||||
its own plan.
|
||||
@@ -85,6 +85,7 @@ semantics, use [Tools and custom providers](/gateway/config-tools).
|
||||
| ----------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `terminal`, `code_execution` | [Exec](/tools/exec), [Control UI terminal](/web/control-ui#operator-terminal), [Code execution](/tools/code-execution) |
|
||||
| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) |
|
||||
| Human input | Pause for a structured decision owned by the user | `ask_user` | [Ask user](/tools/ask-user) |
|
||||
| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) |
|
||||
| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) |
|
||||
| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) |
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { createDiscordQuestionButton } from "./questions.js";
|
||||
import type { ThreadBindingManager } from "./thread-bindings.types.js";
|
||||
|
||||
type DiscordVoiceManager = import("../voice/manager.js").DiscordVoiceManager;
|
||||
@@ -111,6 +112,20 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
}
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordQuestionButton({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
authContext: {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
discordConfig: params.discordConfig,
|
||||
runtime: params.runtime,
|
||||
token: params.token,
|
||||
guildEntries: params.guildEntries,
|
||||
allowFrom: params.allowFrom,
|
||||
dmPolicy: params.dmPolicy,
|
||||
},
|
||||
}),
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Discord question component feedback tests.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ButtonInteraction } from "../internal/discord.js";
|
||||
import { createDiscordQuestionButton } from "./questions.js";
|
||||
|
||||
type InteractionHarness = {
|
||||
interaction: ButtonInteraction;
|
||||
acknowledge: ReturnType<typeof vi.fn>;
|
||||
followUp: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createInteraction(): InteractionHarness {
|
||||
const acknowledge = vi.fn();
|
||||
const followUp = vi.fn();
|
||||
const interaction = {
|
||||
userId: "user-1",
|
||||
reply: vi.fn(),
|
||||
acknowledge,
|
||||
followUp,
|
||||
} as unknown as ButtonInteraction;
|
||||
return { interaction, acknowledge, followUp };
|
||||
}
|
||||
|
||||
describe("Discord question button", () => {
|
||||
it.each([
|
||||
[{ status: "answered", questionId: "target", optionValue: "Production" }, "Answer submitted."],
|
||||
[
|
||||
{ status: "already-terminal", reason: "already-terminal" },
|
||||
"This question was already answered.",
|
||||
],
|
||||
] as const)("shows ephemeral outcome feedback", async (result, expectedText) => {
|
||||
const { interaction, acknowledge, followUp } = createInteraction();
|
||||
const button = createDiscordQuestionButton({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
authorizeQuestion: vi.fn(async () => true),
|
||||
resolveQuestion: vi.fn(async () => result),
|
||||
});
|
||||
|
||||
await button.run(interaction, {
|
||||
id: "ask_0123456789abcdef0123456789abcdef",
|
||||
i: "1",
|
||||
});
|
||||
|
||||
expect(acknowledge).toHaveBeenCalledOnce();
|
||||
expect(followUp).toHaveBeenCalledWith({ content: expectedText, ephemeral: true });
|
||||
});
|
||||
|
||||
it("does not resolve unauthorized clicks", async () => {
|
||||
const { interaction, acknowledge } = createInteraction();
|
||||
const resolveQuestion = vi.fn();
|
||||
const button = createDiscordQuestionButton({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
authorizeQuestion: vi.fn(async () => false),
|
||||
resolveQuestion,
|
||||
});
|
||||
|
||||
await button.run(interaction, {
|
||||
id: "ask_0123456789abcdef0123456789abcdef",
|
||||
i: "1",
|
||||
});
|
||||
|
||||
expect(resolveQuestion).not.toHaveBeenCalled();
|
||||
expect(acknowledge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not turn a committed answer into an error when feedback fails", async () => {
|
||||
const { interaction, followUp } = createInteraction();
|
||||
followUp.mockRejectedValue(new Error("receipt failed"));
|
||||
const button = createDiscordQuestionButton({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
authorizeQuestion: vi.fn(async () => true),
|
||||
resolveQuestion: vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(
|
||||
button.run(interaction, {
|
||||
id: "ask_0123456789abcdef0123456789abcdef",
|
||||
i: "1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(followUp).toHaveBeenCalledOnce();
|
||||
expect(followUp).toHaveBeenCalledWith({
|
||||
content: "Answer submitted.",
|
||||
ephemeral: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Discord ask_user component dispatch and ephemeral feedback.
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import {
|
||||
resolveQuestionOverGateway,
|
||||
type ResolveQuestionOverGatewayParams,
|
||||
} from "openclaw/plugin-sdk/question-gateway-runtime";
|
||||
import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js";
|
||||
import { parseDiscordQuestionData } from "../question-custom-id.js";
|
||||
import {
|
||||
type AgentComponentContext,
|
||||
resolveAuthorizedComponentInteraction,
|
||||
} from "./agent-components-helpers.js";
|
||||
|
||||
type QuestionResolver = (
|
||||
params: ResolveQuestionOverGatewayParams,
|
||||
) => ReturnType<typeof resolveQuestionOverGateway>;
|
||||
|
||||
class QuestionButton extends Button {
|
||||
override label = "question";
|
||||
customId = "ocq:id=seed;i=0";
|
||||
override style = ButtonStyle.Primary;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: {
|
||||
cfg: ResolveQuestionOverGatewayParams["cfg"];
|
||||
accountId: string;
|
||||
resolveQuestion: QuestionResolver;
|
||||
authorizeQuestion: (interaction: ButtonInteraction) => Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
const callback = parseDiscordQuestionData(data);
|
||||
if (!callback) {
|
||||
await interaction.reply({ content: "This question is no longer valid.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (!(await this.ctx.authorizeQuestion(interaction))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await interaction.acknowledge();
|
||||
} catch {}
|
||||
let result: Awaited<ReturnType<QuestionResolver>>;
|
||||
try {
|
||||
result = await this.ctx.resolveQuestion({
|
||||
cfg: this.ctx.cfg,
|
||||
questionId: callback.questionId,
|
||||
optionIndex: callback.optionIndex,
|
||||
senderId: interaction.userId,
|
||||
clientDisplayName: `Discord question (${this.ctx.accountId})`,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await interaction.followUp({ content: "Could not submit this answer.", ephemeral: true });
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await interaction.followUp({
|
||||
content:
|
||||
result.status === "answered"
|
||||
? "Answer submitted."
|
||||
: "This question was already answered.",
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Gateway state already committed; receipt delivery is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordQuestionButton(params: {
|
||||
cfg: ResolveQuestionOverGatewayParams["cfg"];
|
||||
accountId: string;
|
||||
authContext?: AgentComponentContext;
|
||||
authorizeQuestion?: (interaction: ButtonInteraction) => Promise<boolean>;
|
||||
resolveQuestion?: QuestionResolver;
|
||||
}): Button {
|
||||
const authContext = params.authContext ?? { cfg: params.cfg, accountId: params.accountId };
|
||||
return new QuestionButton({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
resolveQuestion: params.resolveQuestion ?? resolveQuestionOverGateway,
|
||||
authorizeQuestion:
|
||||
params.authorizeQuestion ??
|
||||
(async (interaction) =>
|
||||
Boolean(
|
||||
await resolveAuthorizedComponentInteraction({
|
||||
ctx: authContext,
|
||||
interaction,
|
||||
label: "discord question",
|
||||
componentLabel: "button",
|
||||
unauthorizedReply: "You are not authorized to answer this question.",
|
||||
defer: false,
|
||||
}),
|
||||
)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Discord question custom-id envelope tests.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomId } from "./internal/discord.js";
|
||||
import { buildDiscordQuestionCustomId, parseDiscordQuestionData } from "./question-custom-id.js";
|
||||
|
||||
describe("question custom id", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
|
||||
it("round-trips a compact option index within Discord's character limit", () => {
|
||||
const customId = buildDiscordQuestionCustomId({ questionId, optionIndex: 3 });
|
||||
|
||||
expect(customId).toBe(`ocq:id=${questionId};i=3`);
|
||||
expect(customId).toHaveLength(47);
|
||||
expect(customId?.length).toBeLessThanOrEqual(100);
|
||||
const parsed = parseCustomId(customId ?? "");
|
||||
expect(parsed.key).toBe("ocq");
|
||||
expect(parseDiscordQuestionData(parsed.data)).toEqual({ questionId, optionIndex: 3 });
|
||||
});
|
||||
|
||||
it("rejects malformed indices", () => {
|
||||
expect(parseDiscordQuestionData(parseCustomId(`ocq:id=${questionId};i=4`).data)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Discord-private ask_user component envelope.
|
||||
import type { ComponentData } from "./internal/discord.js";
|
||||
|
||||
const DISCORD_QUESTION_CUSTOM_ID_MAX_CHARS = 100;
|
||||
const QUESTION_RECORD_ID_PATTERN = /^ask_[a-f0-9]{32}$/u;
|
||||
|
||||
type DiscordQuestionCallback = {
|
||||
questionId: string;
|
||||
optionIndex: number;
|
||||
};
|
||||
|
||||
export function buildDiscordQuestionCustomId(
|
||||
callback: DiscordQuestionCallback,
|
||||
): string | undefined {
|
||||
if (
|
||||
!QUESTION_RECORD_ID_PATTERN.test(callback.questionId) ||
|
||||
!Number.isInteger(callback.optionIndex) ||
|
||||
callback.optionIndex < 0 ||
|
||||
callback.optionIndex > 3
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const customId = `ocq:id=${callback.questionId};i=${callback.optionIndex}`;
|
||||
return customId.length <= DISCORD_QUESTION_CUSTOM_ID_MAX_CHARS ? customId : undefined;
|
||||
}
|
||||
|
||||
export function parseDiscordQuestionData(data: ComponentData): DiscordQuestionCallback | null {
|
||||
const questionId = typeof data.id === "string" ? data.id : "";
|
||||
const rawIndex =
|
||||
typeof data.i === "string" ? data.i : typeof data.i === "number" ? String(data.i) : "";
|
||||
if (!QUESTION_RECORD_ID_PATTERN.test(questionId) || !/^[0-3]$/u.test(rawIndex)) {
|
||||
return null;
|
||||
}
|
||||
return { questionId, optionIndex: Number(rawIndex) };
|
||||
}
|
||||
@@ -271,6 +271,37 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders question choices with compact option indices", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
expect(
|
||||
buildDiscordPresentationComponents({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: ["Staging", "Production"].map((label) => ({
|
||||
label,
|
||||
action: { type: "question" as const, questionId, optionValue: label },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{ label: "Staging", style: "secondary", internalCustomId: `ocq:id=${questionId};i=0` },
|
||||
{
|
||||
label: "Production",
|
||||
style: "secondary",
|
||||
internalCustomId: `ocq:id=${questionId};i=1`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed approval custom ids and compacts overlong canonical ids", () => {
|
||||
expect(
|
||||
parseExecApprovalData(parseCustomId("execapproval:kind=exec;id=%zz;action=allow-once").data),
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
DiscordComponentButtonStyle,
|
||||
DiscordComponentMessageSpec,
|
||||
} from "./components.types.js";
|
||||
import { buildDiscordQuestionCustomId } from "./question-custom-id.js";
|
||||
|
||||
function resolveDiscordInteractiveButtonStyle(
|
||||
style?: InteractiveButtonStyle,
|
||||
@@ -65,6 +66,7 @@ const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
|
||||
|
||||
function buildDiscordButtonComponent(
|
||||
button: MessagePresentationButton,
|
||||
optionIndex: number,
|
||||
): DiscordComponentButtonSpec | undefined {
|
||||
const action = resolveMessagePresentationButtonAction(button);
|
||||
if (!action) {
|
||||
@@ -82,6 +84,20 @@ function buildDiscordButtonComponent(
|
||||
...(button.disabled === true ? { disabled: true } : {}),
|
||||
};
|
||||
}
|
||||
if (action.type === "question") {
|
||||
const internalCustomId = buildDiscordQuestionCustomId({
|
||||
questionId: action.questionId,
|
||||
optionIndex,
|
||||
});
|
||||
return internalCustomId
|
||||
? {
|
||||
label: button.label,
|
||||
style: resolveDiscordInteractiveButtonStyle(button.style),
|
||||
internalCustomId,
|
||||
...(button.disabled === true ? { disabled: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
if (
|
||||
action.type === "web-app" &&
|
||||
action.widgetId &&
|
||||
@@ -127,7 +143,7 @@ function appendDiscordButtonBlocks(
|
||||
buttons: readonly MessagePresentationButton[],
|
||||
): void {
|
||||
const components = buttons
|
||||
.map((button) => buildDiscordButtonComponent(button))
|
||||
.map((button, optionIndex) => buildDiscordButtonComponent(button, optionIndex))
|
||||
.filter((button): button is DiscordComponentButtonSpec => Boolean(button));
|
||||
for (let index = 0; index < components.length; index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) {
|
||||
blocks.push({
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
SLACK_SECTION_TEXT_MAX,
|
||||
SLACK_STATIC_SELECT_OPTIONS_MAX,
|
||||
} from "./presentation.js";
|
||||
import { encodeSlackQuestionAction } from "./question-actions.js";
|
||||
import {
|
||||
SLACK_APPROVAL_BUTTON_ACTION_ID,
|
||||
SLACK_APPROVAL_SELECT_ACTION_ID,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
SLACK_REPLY_LINK_ACTION_ID,
|
||||
SLACK_REPLY_SELECT_ACTION_ID,
|
||||
SLACK_QUESTION_BUTTON_ACTION_ID,
|
||||
} from "./reply-action-ids.js";
|
||||
import { truncateSlackText } from "./truncate.js";
|
||||
|
||||
@@ -89,6 +91,10 @@ function buildSlackCallbackSelectActionId(selectIndex: number): string {
|
||||
return `${SLACK_CALLBACK_SELECT_ACTION_ID}:${String(selectIndex)}`;
|
||||
}
|
||||
|
||||
function buildSlackQuestionButtonActionId(buttonIndex: number, choiceIndex: number): string {
|
||||
return `${SLACK_QUESTION_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
||||
}
|
||||
|
||||
function resolveSlackButtonStyle(
|
||||
style: "primary" | "secondary" | "success" | "danger" | undefined,
|
||||
) {
|
||||
@@ -105,10 +111,12 @@ type SlackActionTarget =
|
||||
| { kind: "approval"; value: string }
|
||||
| { kind: "callback"; value: string }
|
||||
| { kind: "link"; url: string }
|
||||
| { kind: "question"; value: string }
|
||||
| { kind: "reply"; value: string };
|
||||
|
||||
function resolveSlackActionTarget(
|
||||
action: MessagePresentationAction | undefined,
|
||||
optionIndex?: number,
|
||||
): SlackActionTarget | undefined {
|
||||
if (!action) {
|
||||
return undefined;
|
||||
@@ -116,6 +124,13 @@ function resolveSlackActionTarget(
|
||||
if (action.type === "approval") {
|
||||
return { kind: "approval", value: encodeSlackApprovalAction(action) };
|
||||
}
|
||||
if (action.type === "question") {
|
||||
const value =
|
||||
optionIndex === undefined
|
||||
? undefined
|
||||
: encodeSlackQuestionAction({ questionId: action.questionId, optionIndex });
|
||||
return value ? { kind: "question", value } : undefined;
|
||||
}
|
||||
if (action.type === "url" || action.type === "web-app") {
|
||||
const url = normalizeOptionalString(action.url);
|
||||
return url ? { kind: "link", url } : undefined;
|
||||
@@ -134,10 +149,11 @@ function resolveSlackActionTarget(
|
||||
|
||||
function resolveSlackButtonTarget(
|
||||
button: MessagePresentationButtonsBlock["buttons"][number],
|
||||
optionIndex?: number,
|
||||
): SlackActionTarget | undefined {
|
||||
if (button.action !== undefined) {
|
||||
const action = resolveMessagePresentationButtonAction(button);
|
||||
return action ? resolveSlackActionTarget(action) : undefined;
|
||||
return action ? resolveSlackActionTarget(action, optionIndex) : undefined;
|
||||
}
|
||||
|
||||
// Legacy buttons could carry both a URL and callback fallback. Preserve the
|
||||
@@ -157,11 +173,11 @@ function resolveSlackButtonTarget(
|
||||
|
||||
function resolveSlackOptionTarget(
|
||||
option: MessagePresentationSelectBlock["options"][number],
|
||||
): Exclude<SlackActionTarget, { kind: "link" }> | undefined {
|
||||
): Exclude<SlackActionTarget, { kind: "link" } | { kind: "question" }> | undefined {
|
||||
if (option.action !== undefined) {
|
||||
const action = resolveMessagePresentationOptionAction(option);
|
||||
const target = action ? resolveSlackActionTarget(action) : undefined;
|
||||
return target?.kind === "link" ? undefined : target;
|
||||
return target?.kind === "link" || target?.kind === "question" ? undefined : target;
|
||||
}
|
||||
const value = normalizeOptionalString(option.value);
|
||||
return value ? { kind: "reply", value } : undefined;
|
||||
@@ -254,7 +270,7 @@ export function buildSlackInteractiveBlocks(
|
||||
if (block.type === "buttons") {
|
||||
const elements = block.buttons
|
||||
.flatMap((button, choiceIndex) => {
|
||||
const target = resolveSlackButtonTarget(button);
|
||||
const target = resolveSlackButtonTarget(button, choiceIndex);
|
||||
if (
|
||||
!target ||
|
||||
(target.kind === "link"
|
||||
@@ -275,7 +291,9 @@ export function buildSlackInteractiveBlocks(
|
||||
? buildSlackApprovalButtonActionId(state.buttonIndex + 1, choiceIndex)
|
||||
: target.kind === "callback"
|
||||
? buildSlackCallbackButtonActionId(state.buttonIndex + 1, choiceIndex)
|
||||
: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
||||
: target.kind === "question"
|
||||
? buildSlackQuestionButtonActionId(state.buttonIndex + 1, choiceIndex)
|
||||
: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
||||
text: {
|
||||
type: "plain_text" as const,
|
||||
text: truncateSlackText(button.label, SLACK_ACTION_LABEL_MAX),
|
||||
@@ -461,7 +479,7 @@ function buildSlackPresentationButtonBlock(
|
||||
): SlackBlock | undefined {
|
||||
const elements = block.buttons
|
||||
.flatMap((button, choiceIndex) => {
|
||||
const target = resolveSlackButtonTarget(button);
|
||||
const target = resolveSlackButtonTarget(button, choiceIndex);
|
||||
if (
|
||||
!target ||
|
||||
(target.kind === "link"
|
||||
@@ -482,7 +500,9 @@ function buildSlackPresentationButtonBlock(
|
||||
? buildSlackApprovalButtonActionId(buttonIndex, choiceIndex)
|
||||
: target.kind === "callback"
|
||||
? buildSlackCallbackButtonActionId(buttonIndex, choiceIndex)
|
||||
: buildSlackReplyButtonActionId(buttonIndex, choiceIndex),
|
||||
: target.kind === "question"
|
||||
? buildSlackQuestionButtonActionId(buttonIndex, choiceIndex)
|
||||
: buildSlackReplyButtonActionId(buttonIndex, choiceIndex),
|
||||
text: {
|
||||
type: "plain_text" as const,
|
||||
text: truncateSlackText(button.label, SLACK_ACTION_LABEL_MAX),
|
||||
@@ -546,11 +566,11 @@ export function canRenderSlackPresentation(
|
||||
if (block.type === "buttons") {
|
||||
const allButtonsRenderable =
|
||||
block.buttons.length <= SLACK_ACTION_BLOCK_ELEMENTS_MAX &&
|
||||
block.buttons.every((button) => {
|
||||
block.buttons.every((button, choiceIndex) => {
|
||||
if (!isWithinSlackLimit(button.label, SLACK_ACTION_LABEL_MAX)) {
|
||||
return false;
|
||||
}
|
||||
const target = resolveSlackButtonTarget(button);
|
||||
const target = resolveSlackButtonTarget(button, choiceIndex);
|
||||
return target
|
||||
? target.kind === "link"
|
||||
? isWithinSlackLimit(target.url, SLACK_BUTTON_URL_MAX)
|
||||
|
||||
@@ -19,9 +19,11 @@ import { decodeSlackApprovalAction, type SlackApprovalAction } from "../../appro
|
||||
import { isSlackApprovalAuthorizedSender } from "../../approval-auth.js";
|
||||
import { isSlackExecApprovalAuthorizedSender } from "../../exec-approvals.js";
|
||||
import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js";
|
||||
import { decodeSlackQuestionAction, resolveSlackQuestionAction } from "../../question-actions.js";
|
||||
import {
|
||||
isSlackApprovalActionId,
|
||||
isSlackCallbackActionId,
|
||||
isSlackQuestionActionId,
|
||||
SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
SLACK_REPLY_LINK_ACTION_ID,
|
||||
SLACK_REPLY_SELECT_ACTION_ID,
|
||||
@@ -1071,6 +1073,25 @@ async function handleSlackBlockAction(params: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isSlackQuestionActionId(parsed.actionId)) {
|
||||
const question = decodeSlackQuestionAction(parsed.actionSummary.value);
|
||||
if (!question) {
|
||||
await respondEphemeral(respond, "This question action is invalid or expired.");
|
||||
return;
|
||||
}
|
||||
const auth = await authorizeSlackBlockAction({ ctx: params.ctx, parsed, respond });
|
||||
if (!auth.allowed) {
|
||||
return;
|
||||
}
|
||||
await resolveSlackQuestionAction({
|
||||
action: question,
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId,
|
||||
userId: parsed.userId,
|
||||
respond: async (text) => await respondEphemeral(respond, text),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pluginInteractionData = buildSlackPluginInteractionData({
|
||||
actionId: parsed.actionId,
|
||||
summary: parsed.actionSummary,
|
||||
|
||||
@@ -32,6 +32,13 @@ const resolveApprovalOverGatewayMock = vi.hoisted(() =>
|
||||
approval: { status: "allowed", decision: "allow-once" },
|
||||
})),
|
||||
);
|
||||
const resolveQuestionOverGatewayMock = vi.hoisted(() =>
|
||||
vi.fn(async (_arg: unknown) => ({
|
||||
status: "answered" as const,
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
})),
|
||||
);
|
||||
|
||||
let registerSlackInteractionEvents: typeof import("./interactions.js").registerSlackInteractionEvents;
|
||||
|
||||
@@ -55,6 +62,10 @@ vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
|
||||
resolveApprovalOverGateway: (arg: unknown) => resolveApprovalOverGatewayMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({
|
||||
resolveQuestionOverGateway: (arg: unknown) => resolveQuestionOverGatewayMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock("../../interactive-dispatch.js", () => ({
|
||||
dispatchSlackPluginInteractiveHandler: (params: {
|
||||
data: string;
|
||||
@@ -425,6 +436,12 @@ describe("registerSlackInteractionEvents", () => {
|
||||
applied: true,
|
||||
approval: { status: "allowed", decision: "allow-once" },
|
||||
});
|
||||
resolveQuestionOverGatewayMock.mockClear();
|
||||
resolveQuestionOverGatewayMock.mockResolvedValue({
|
||||
status: "answered",
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: false,
|
||||
handled: false,
|
||||
@@ -1285,6 +1302,46 @@ describe("registerSlackInteractionEvents", () => {
|
||||
expect(respond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves typed question buttons without enqueueing an agent interaction", async () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
const { ctx, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await getHandler()({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
channel: { id: "C1" },
|
||||
container: { channel_id: "C1", message_ts: "100.200" },
|
||||
message: { ts: "100.200", text: "Question", blocks: [] },
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:question_button:1:2",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
value: `slq1:${questionId}:1`,
|
||||
text: { type: "plain_text", text: "Production" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalledOnce();
|
||||
expect(resolveQuestionOverGatewayMock).toHaveBeenCalledWith({
|
||||
cfg: ctx.cfg,
|
||||
questionId,
|
||||
optionIndex: 1,
|
||||
senderId: "U123",
|
||||
clientDisplayName: "Slack question (default)",
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "Answer submitted.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans stale typed buttons and shows the canonical first-answer winner", async () => {
|
||||
resolveApprovalOverGatewayMock.mockResolvedValueOnce({
|
||||
applied: false,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Slack question envelope and feedback tests.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
decodeSlackQuestionAction,
|
||||
encodeSlackQuestionAction,
|
||||
resolveSlackQuestionAction,
|
||||
} from "./question-actions.js";
|
||||
|
||||
describe("Slack question actions", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
|
||||
it("round-trips a compact option index within Slack's value limit", () => {
|
||||
const value = encodeSlackQuestionAction({ questionId, optionIndex: 3 });
|
||||
|
||||
expect(value).toBe(`slq1:${questionId}:3`);
|
||||
expect(value).toHaveLength(43);
|
||||
expect(value?.length).toBeLessThanOrEqual(2000);
|
||||
expect(decodeSlackQuestionAction(value)).toEqual({ questionId, optionIndex: 3 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ status: "answered", questionId: "target", optionValue: "Production" }, "Answer submitted."],
|
||||
[{ status: "already-terminal", reason: "not-found" }, "This question was already answered."],
|
||||
] as const)("shows ephemeral-ready outcome feedback", async (result, expectedText) => {
|
||||
const respond = vi.fn(async () => undefined);
|
||||
|
||||
await resolveSlackQuestionAction({
|
||||
action: { questionId, optionIndex: 1 },
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
userId: "U123",
|
||||
respond,
|
||||
resolveQuestion: vi.fn(async () => result),
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(expectedText);
|
||||
});
|
||||
|
||||
it("does not turn a committed answer into an error when feedback fails", async () => {
|
||||
const respond = vi.fn(async () => {
|
||||
throw new Error("receipt failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSlackQuestionAction({
|
||||
action: { questionId, optionIndex: 1 },
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
userId: "U123",
|
||||
respond,
|
||||
resolveQuestion: vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
})),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(respond).toHaveBeenCalledOnce();
|
||||
expect(respond).toHaveBeenCalledWith("Answer submitted.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Slack-private ask_user button envelope and resolution feedback.
|
||||
import {
|
||||
resolveQuestionOverGateway,
|
||||
type ResolveQuestionOverGatewayParams,
|
||||
} from "openclaw/plugin-sdk/question-gateway-runtime";
|
||||
import { SLACK_BUTTON_VALUE_MAX } from "./presentation.js";
|
||||
|
||||
const SLACK_QUESTION_VALUE_PREFIX = "slq1:";
|
||||
const QUESTION_RECORD_ID_PATTERN = /^ask_[a-f0-9]{32}$/u;
|
||||
|
||||
type SlackQuestionAction = {
|
||||
questionId: string;
|
||||
optionIndex: number;
|
||||
};
|
||||
|
||||
export function encodeSlackQuestionAction(action: SlackQuestionAction): string | undefined {
|
||||
if (
|
||||
!QUESTION_RECORD_ID_PATTERN.test(action.questionId) ||
|
||||
!Number.isInteger(action.optionIndex) ||
|
||||
action.optionIndex < 0 ||
|
||||
action.optionIndex > 3
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const value = `${SLACK_QUESTION_VALUE_PREFIX}${action.questionId}:${action.optionIndex}`;
|
||||
return value.length <= SLACK_BUTTON_VALUE_MAX ? value : undefined;
|
||||
}
|
||||
|
||||
export function decodeSlackQuestionAction(value: unknown): SlackQuestionAction | null {
|
||||
if (typeof value !== "string" || value.length > SLACK_BUTTON_VALUE_MAX) {
|
||||
return null;
|
||||
}
|
||||
const match = /^slq1:(ask_[a-f0-9]{32}):([0-3])$/u.exec(value);
|
||||
return match?.[1] && match[2] ? { questionId: match[1], optionIndex: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
type QuestionResolver = (
|
||||
params: ResolveQuestionOverGatewayParams,
|
||||
) => ReturnType<typeof resolveQuestionOverGateway>;
|
||||
|
||||
export async function resolveSlackQuestionAction(params: {
|
||||
action: SlackQuestionAction;
|
||||
cfg: ResolveQuestionOverGatewayParams["cfg"];
|
||||
accountId: string;
|
||||
userId: string;
|
||||
respond: (text: string) => Promise<void>;
|
||||
resolveQuestion?: QuestionResolver;
|
||||
}): Promise<void> {
|
||||
let result: Awaited<ReturnType<QuestionResolver>>;
|
||||
try {
|
||||
result = await (params.resolveQuestion ?? resolveQuestionOverGateway)({
|
||||
cfg: params.cfg,
|
||||
questionId: params.action.questionId,
|
||||
optionIndex: params.action.optionIndex,
|
||||
senderId: params.userId,
|
||||
clientDisplayName: `Slack question (${params.accountId})`,
|
||||
});
|
||||
} catch {
|
||||
await params.respond("Could not submit this answer.").catch(() => {});
|
||||
return;
|
||||
}
|
||||
await params
|
||||
.respond(
|
||||
result.status === "answered" ? "Answer submitted." : "This question was already answered.",
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -6,6 +6,14 @@ export const SLACK_CALLBACK_BUTTON_ACTION_ID = "openclaw:callback_button";
|
||||
export const SLACK_CALLBACK_SELECT_ACTION_ID = "openclaw:callback_select";
|
||||
export const SLACK_APPROVAL_BUTTON_ACTION_ID = "openclaw:approval_button";
|
||||
export const SLACK_APPROVAL_SELECT_ACTION_ID = "openclaw:approval_select";
|
||||
export const SLACK_QUESTION_BUTTON_ACTION_ID = "openclaw:question_button";
|
||||
|
||||
export function isSlackQuestionActionId(actionId: string): boolean {
|
||||
return (
|
||||
actionId === SLACK_QUESTION_BUTTON_ACTION_ID ||
|
||||
actionId.startsWith(`${SLACK_QUESTION_BUTTON_ACTION_ID}:`)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSlackApprovalActionId(actionId: string): boolean {
|
||||
return (
|
||||
|
||||
@@ -342,6 +342,38 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
});
|
||||
|
||||
describe("buildSlackPresentationBlocks", () => {
|
||||
it("renders question choices with compact private indices", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
expect(
|
||||
buildSlackPresentationBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: ["Staging", "Production"].map((label) => ({
|
||||
label,
|
||||
action: { type: "question" as const, questionId, optionValue: label },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
expect.objectContaining({
|
||||
action_id: "openclaw:question_button:1:1",
|
||||
value: `slq1:${questionId}:0`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
action_id: "openclaw:question_button:1:2",
|
||||
value: `slq1:${questionId}:1`,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders presentation blocks in authored order", () => {
|
||||
const blocks = buildSlackPresentationBlocks({
|
||||
blocks: [
|
||||
|
||||
@@ -27,7 +27,11 @@ import {
|
||||
resolveTelegramEventIngressAuthorization,
|
||||
} from "./ingress.js";
|
||||
|
||||
export type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
|
||||
export type TelegramEventAuthorizationMode =
|
||||
| "reaction"
|
||||
| "callback-scope"
|
||||
| "callback-allowlist"
|
||||
| "callback-runtime-allowlist";
|
||||
|
||||
export function createTelegramHandlerAuthorizationRuntime({
|
||||
accountId,
|
||||
@@ -77,6 +81,12 @@ export function createTelegramHandlerAuthorizationRuntime({
|
||||
deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist",
|
||||
deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist",
|
||||
},
|
||||
"callback-runtime-allowlist": {
|
||||
enforceDirectAuthorization: true,
|
||||
enforceGroupAllowlistAuthorization: true,
|
||||
deniedDmReason: "runtime callback unauthorized by allowlist",
|
||||
deniedGroupReason: "runtime callback unauthorized by group allowlist",
|
||||
},
|
||||
};
|
||||
|
||||
// Authorization owns one ingress snapshot. The agent turn intentionally
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Telegram question callback feedback tests.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
|
||||
|
||||
const callback = {
|
||||
questionId: "ask_0123456789abcdef0123456789abcdef",
|
||||
optionIndex: 1,
|
||||
};
|
||||
|
||||
describe("handleTelegramQuestionCallback", () => {
|
||||
it.each([
|
||||
[{ status: "answered", questionId: "target", optionValue: "Production" }, "Answer submitted."],
|
||||
[
|
||||
{ status: "already-terminal", reason: "already-terminal" },
|
||||
"This question was already answered.",
|
||||
],
|
||||
] as const)("shows outcome feedback", async (result, expectedText) => {
|
||||
const feedback = vi.fn(async () => undefined);
|
||||
const resolveQuestion = vi.fn(async () => result);
|
||||
|
||||
await handleTelegramQuestionCallback({
|
||||
callback,
|
||||
cfg: {} as never,
|
||||
senderId: "42",
|
||||
feedback,
|
||||
resolveQuestion,
|
||||
});
|
||||
|
||||
expect(feedback).toHaveBeenCalledWith(expectedText, true);
|
||||
});
|
||||
|
||||
it("does not turn a committed answer into an error when feedback fails", async () => {
|
||||
const feedback = vi.fn(async () => {
|
||||
throw new Error("receipt failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleTelegramQuestionCallback({
|
||||
callback,
|
||||
cfg: {} as never,
|
||||
senderId: "42",
|
||||
feedback,
|
||||
resolveQuestion: vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
})),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(feedback).toHaveBeenCalledOnce();
|
||||
expect(feedback).toHaveBeenCalledWith("Answer submitted.", true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Telegram ask_user callback resolution and toast feedback.
|
||||
import {
|
||||
resolveQuestionOverGateway,
|
||||
type ResolveQuestionOverGatewayParams,
|
||||
} from "openclaw/plugin-sdk/question-gateway-runtime";
|
||||
import type { TelegramQuestionCallback } from "./question-callback-data.js";
|
||||
|
||||
type QuestionResolver = (
|
||||
params: ResolveQuestionOverGatewayParams,
|
||||
) => ReturnType<typeof resolveQuestionOverGateway>;
|
||||
|
||||
export async function handleTelegramQuestionCallback(params: {
|
||||
callback: TelegramQuestionCallback;
|
||||
cfg: ResolveQuestionOverGatewayParams["cfg"];
|
||||
senderId: string;
|
||||
feedback: (text: string, terminal: boolean) => Promise<unknown>;
|
||||
resolveQuestion?: QuestionResolver;
|
||||
}): Promise<void> {
|
||||
let result: Awaited<ReturnType<QuestionResolver>>;
|
||||
try {
|
||||
result = await (params.resolveQuestion ?? resolveQuestionOverGateway)({
|
||||
cfg: params.cfg,
|
||||
questionId: params.callback.questionId,
|
||||
optionIndex: params.callback.optionIndex,
|
||||
senderId: params.senderId,
|
||||
clientDisplayName: "Telegram question",
|
||||
});
|
||||
} catch (error) {
|
||||
await params.feedback("Could not submit this answer.", false).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
await params
|
||||
.feedback(
|
||||
result.status === "answered" ? "Answer submitted." : "This question was already answered.",
|
||||
true,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./bot-handlers.callback-errors.runtime.js";
|
||||
import { handleTelegramInteractiveCallback } from "./bot-handlers.callback-interactions.runtime.js";
|
||||
import { handleTelegramModelCallback } from "./bot-handlers.callback-model.runtime.js";
|
||||
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
|
||||
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
|
||||
import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js";
|
||||
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
||||
@@ -30,6 +31,10 @@ import type { TelegramGetChat } from "./bot/types.js";
|
||||
import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||
import { parseTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
|
||||
import {
|
||||
hasTelegramQuestionCallbackPrefix,
|
||||
parseTelegramQuestionCallbackData,
|
||||
} from "./question-callback-data.js";
|
||||
|
||||
export function registerTelegramCallbackQueryHandler(
|
||||
{ accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams,
|
||||
@@ -47,26 +52,46 @@ export function registerTelegramCallbackQueryHandler(
|
||||
|
||||
bot.on("callback_query", async (ctx) => {
|
||||
const callback = ctx.callbackQuery;
|
||||
if (!callback || shouldSkipUpdate(ctx)) {
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const answerCallbackQuery = async () => {
|
||||
let callbackAnswered = false;
|
||||
const answerCallbackQuery = async (text?: string) => {
|
||||
// Callback answers prevent Telegram retries while the routed action runs.
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "answerCallbackQuery",
|
||||
runtime,
|
||||
fn: () => bot.api.answerCallbackQuery(callback.id),
|
||||
fn: () =>
|
||||
text
|
||||
? bot.api.answerCallbackQuery(callback.id, { text })
|
||||
: bot.api.answerCallbackQuery(callback.id),
|
||||
}).catch(() => {});
|
||||
callbackAnswered = true;
|
||||
};
|
||||
if (shouldSkipUpdate(ctx)) {
|
||||
const earlyAnswerPromise = getTelegramCallbackQueryAnswerPromise(ctx);
|
||||
if (earlyAnswerPromise) {
|
||||
await earlyAnswerPromise.catch(async () => await answerCallbackQuery());
|
||||
} else {
|
||||
await answerCallbackQuery();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (callback.data ?? "").trim();
|
||||
const typedQuestionCallback = parseTelegramQuestionCallbackData(data);
|
||||
const earlyAnswerPromise = getTelegramCallbackQueryAnswerPromise(ctx);
|
||||
if (earlyAnswerPromise) {
|
||||
await earlyAnswerPromise.catch(answerCallbackQuery);
|
||||
try {
|
||||
await earlyAnswerPromise;
|
||||
callbackAnswered = true;
|
||||
} catch {
|
||||
await answerCallbackQuery();
|
||||
}
|
||||
} else {
|
||||
await answerCallbackQuery();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = (callback.data ?? "").trim();
|
||||
const callbackMessage = callback.message;
|
||||
if (!data || !callbackMessage) {
|
||||
return;
|
||||
@@ -80,18 +105,20 @@ export function registerTelegramCallbackQueryHandler(
|
||||
const callbackCommandText =
|
||||
nativeCallbackCommand ?? (opaqueCallbackData ? "" : genericCallbackText);
|
||||
const hasReservedApprovalPrefix = hasTelegramApprovalCallbackPrefix(data);
|
||||
const hasReservedQuestionPrefix = hasTelegramQuestionCallbackPrefix(data);
|
||||
const typedApprovalCallback = parseTelegramApprovalCallbackData(data);
|
||||
const legacyApprovalCallback = parseExecApprovalCommandText(
|
||||
nativeCallbackCommand ?? (opaqueCallbackData ? "" : data),
|
||||
);
|
||||
const isApprovalCallback = hasReservedApprovalPrefix || legacyApprovalCallback !== null;
|
||||
const isRuntimeControlCallback = isApprovalCallback || hasReservedQuestionPrefix;
|
||||
const authorizationCfg = telegramDeps.getRuntimeConfig();
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg: authorizationCfg,
|
||||
accountId,
|
||||
});
|
||||
// Approval controls retain their kind-specific authorization after capability changes.
|
||||
if (!isApprovalCallback) {
|
||||
// Runtime controls retain their authorization after inline-button capability changes.
|
||||
if (!isRuntimeControlCallback) {
|
||||
if (
|
||||
inlineButtonsScope === "off" ||
|
||||
(inlineButtonsScope === "dm" && isGroup) ||
|
||||
@@ -128,8 +155,9 @@ export function registerTelegramCallbackQueryHandler(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const authorizationMode: TelegramEventAuthorizationMode =
|
||||
!isGroup || (!isApprovalCallback && inlineButtonsScope === "allowlist")
|
||||
const authorizationMode: TelegramEventAuthorizationMode = hasReservedQuestionPrefix
|
||||
? "callback-runtime-allowlist"
|
||||
: !isGroup || (!isRuntimeControlCallback && inlineButtonsScope === "allowlist")
|
||||
? "callback-allowlist"
|
||||
: "callback-scope";
|
||||
const senderAuthorization = await authorizeTelegramEventSender({
|
||||
@@ -175,6 +203,23 @@ export function registerTelegramCallbackQueryHandler(
|
||||
await approvalRuntime.handleCanonical(typedApprovalCallback);
|
||||
return;
|
||||
}
|
||||
if (typedQuestionCallback) {
|
||||
await handleTelegramQuestionCallback({
|
||||
callback: typedQuestionCallback,
|
||||
cfg: runtimeCfg,
|
||||
senderId,
|
||||
feedback: async (text, terminal) => {
|
||||
if (terminal) {
|
||||
await actions.clearCallbackButtons().catch(() => {});
|
||||
}
|
||||
await actions.replyToCallbackChat(text);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasReservedQuestionPrefix) {
|
||||
return;
|
||||
}
|
||||
if (hasReservedApprovalPrefix) {
|
||||
await approvalRuntime.handleMalformedReserved();
|
||||
return;
|
||||
@@ -258,6 +303,10 @@ export function registerTelegramCallbackQueryHandler(
|
||||
if (isTelegramSpooledReplayUpdate(ctx.update)) {
|
||||
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
|
||||
}
|
||||
} finally {
|
||||
if (typedQuestionCallback && !callbackAnswered) {
|
||||
await answerCallbackQuery();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -491,6 +491,53 @@ describe("createTelegramBot", () => {
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("acknowledges question callbacks before their handler completes", async () => {
|
||||
installPerKeySequentializer();
|
||||
loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "disabled" } } });
|
||||
createTelegramBot({ token: "tok" });
|
||||
const callbackHandler = requireValue(
|
||||
getOnHandler("callback_query") as
|
||||
| ((ctx: Record<string, unknown>) => Promise<void>)
|
||||
| undefined,
|
||||
"callback_query handler",
|
||||
);
|
||||
answerCallbackQuerySpy.mockClear();
|
||||
let releaseHandler: (() => void) | undefined;
|
||||
const handlerGate = new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
const callbackQuery = {
|
||||
id: "cbq-question-early-ack",
|
||||
data: "tgq1:ask_0123456789abcdef0123456789abcdef:1",
|
||||
from: { id: 9, first_name: "Ada", username: "ada_bot" },
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
};
|
||||
const pending = runTelegramMiddlewareChain({
|
||||
ctx: {
|
||||
update: { update_id: 403, callback_query: callbackQuery },
|
||||
callbackQuery,
|
||||
me: { username: "openclaw_bot" },
|
||||
},
|
||||
finalHandler: async (ctx) => {
|
||||
await callbackHandler(ctx);
|
||||
await handlerGate;
|
||||
},
|
||||
});
|
||||
await flushTelegramTestMicrotasks();
|
||||
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-question-early-ack");
|
||||
if (!releaseHandler) {
|
||||
throw new Error("Expected Telegram question callback release callback to be initialized");
|
||||
}
|
||||
releaseHandler();
|
||||
await pending;
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets /status bypass a busy Telegram topic lane", async () => {
|
||||
installPerKeySequentializer();
|
||||
loadConfig.mockReturnValue({
|
||||
@@ -2884,8 +2931,8 @@ describe("createTelegramBot", () => {
|
||||
await callbackHandler({
|
||||
update: { update_id: 222 },
|
||||
callbackQuery: {
|
||||
id: "cb-1",
|
||||
data: "ping",
|
||||
id: "cb-question-duplicate",
|
||||
data: "tgq1:ask_0123456789abcdef0123456789abcdef:1",
|
||||
from: { id: 789, username: "testuser" },
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
@@ -2897,6 +2944,7 @@ describe("createTelegramBot", () => {
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cb-question-duplicate");
|
||||
|
||||
replySpy.mockClear();
|
||||
|
||||
|
||||
@@ -33,6 +33,19 @@ import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.
|
||||
import { setTelegramRuntime } from "./runtime.js";
|
||||
import { clearTelegramRuntimeForTest as clearTelegramRuntime } from "./runtime.test-support.js";
|
||||
import type { TelegramRuntime } from "./runtime.types.js";
|
||||
|
||||
const questionGatewayHoisted = vi.hoisted(() => ({
|
||||
resolveQuestionOverGatewaySpy: vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
questionId: "target",
|
||||
optionValue: "Production",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({
|
||||
resolveQuestionOverGateway: questionGatewayHoisted.resolveQuestionOverGatewaySpy,
|
||||
}));
|
||||
|
||||
const {
|
||||
answerCallbackQuerySpy,
|
||||
commandSpy,
|
||||
@@ -380,6 +393,7 @@ describe("createTelegramBot", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setMyCommandsSpy.mockClear();
|
||||
questionGatewayHoisted.resolveQuestionOverGatewaySpy.mockClear();
|
||||
clearPluginInteractiveHandlers();
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
@@ -1013,6 +1027,44 @@ describe("createTelegramBot", () => {
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
|
||||
});
|
||||
|
||||
it("keeps group question callbacks on the configured callback allowlist", async () => {
|
||||
onSpy.mockClear();
|
||||
answerCallbackQuerySpy.mockClear();
|
||||
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["9"],
|
||||
capabilities: { inlineButtons: "all" },
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false, allowFrom: ["9"] } },
|
||||
},
|
||||
},
|
||||
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
|
||||
loadConfig.mockReturnValue(config);
|
||||
createTelegramBot({ token: "tok", config });
|
||||
const callbackHandler = getTelegramCallbackHandlerForTests();
|
||||
|
||||
await callbackHandler({
|
||||
callbackQuery: {
|
||||
id: "cbq-question-blocked",
|
||||
data: "tgq1:ask_0123456789abcdef0123456789abcdef:1",
|
||||
from: { id: 999, first_name: "Mallory", username: "mallory" },
|
||||
message: {
|
||||
chat: { id: -100999, type: "supergroup", title: "Test Group" },
|
||||
date: 1736380800,
|
||||
message_id: 21,
|
||||
},
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(questionGatewayHoisted.resolveQuestionOverGatewaySpy).not.toHaveBeenCalled();
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-question-blocked");
|
||||
});
|
||||
|
||||
it("replaces legacy approval controls with a visible terminal receipt", async () => {
|
||||
onSpy.mockClear();
|
||||
editMessageReplyMarkupSpy.mockClear();
|
||||
|
||||
@@ -54,6 +54,28 @@ describe("buildTelegramPresentationButtons", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("encodes question buttons by record id and option index", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
expect(
|
||||
buildTelegramPresentationButtons({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: ["Staging", "Production"].map((label) => ({
|
||||
label,
|
||||
action: { type: "question" as const, questionId, optionValue: label },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
[
|
||||
{ text: "Staging", callback_data: `tgq1:${questionId}:0`, style: undefined },
|
||||
{ text: "Production", callback_data: `tgq1:${questionId}:1`, style: undefined },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops presentation buttons whose callback payload exceeds Telegram limits", () => {
|
||||
expect(
|
||||
buildTelegramPresentationButtons({
|
||||
@@ -150,6 +172,35 @@ describe("buildTelegramPresentationButtons", () => {
|
||||
expect(parseTelegramOpaqueCallbackData(callbackData)).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps transport-private question callback prefixes opaque for legacy values", () => {
|
||||
const value = "tgq1:ask_0123456789abcdef0123456789abcdef:0";
|
||||
const callbackData = buildTelegramOpaqueCallbackData(value);
|
||||
|
||||
expect(
|
||||
buildTelegramPresentationButtons({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Plugin", value }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([[{ text: "Plugin", callback_data: callbackData, style: undefined }]]);
|
||||
expect(parseTelegramOpaqueCallbackData(callbackData)).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps trimmed transport-private question prefixes opaque", () => {
|
||||
const value = " tgq1:ask_0123456789abcdef0123456789abcdef:0 ";
|
||||
const callbackData = buildTelegramOpaqueCallbackData(value);
|
||||
|
||||
expect(
|
||||
buildTelegramPresentationButtons({
|
||||
blocks: [{ type: "buttons", buttons: [{ label: "Plugin", value }] }],
|
||||
}),
|
||||
).toEqual([[{ text: "Plugin", callback_data: callbackData, style: undefined }]]);
|
||||
expect(parseTelegramOpaqueCallbackData(callbackData)).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps shortened plugin approval callbacks on the approval bypass path", () => {
|
||||
const approvalId = `plugin:${"a".repeat(36)}`;
|
||||
expect(
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
buildTelegramNativeCommandCallbackData,
|
||||
buildTelegramOpaqueCallbackData,
|
||||
} from "./native-command-callback-data.js";
|
||||
import {
|
||||
buildTelegramQuestionCallbackData,
|
||||
hasTelegramQuestionCallbackPrefix,
|
||||
} from "./question-callback-data.js";
|
||||
|
||||
export type TelegramButtonStyle = "danger" | "success" | "primary";
|
||||
|
||||
@@ -43,6 +47,7 @@ function toTelegramButtonStyle(
|
||||
|
||||
function toTelegramInlineButton(
|
||||
button: MessagePresentationButton,
|
||||
optionIndex: number,
|
||||
): TelegramInlineButton | undefined {
|
||||
const style = toTelegramButtonStyle(button.style);
|
||||
const action = resolveMessagePresentationButtonAction(button);
|
||||
@@ -59,6 +64,13 @@ function toTelegramInlineButton(
|
||||
const callbackData = buildTelegramApprovalCallbackData(action);
|
||||
return callbackData ? { text: button.label, callback_data: callbackData, style } : undefined;
|
||||
}
|
||||
if (action.type === "question") {
|
||||
const callbackData = buildTelegramQuestionCallbackData({
|
||||
questionId: action.questionId,
|
||||
optionIndex,
|
||||
});
|
||||
return callbackData ? { text: button.label, callback_data: callbackData, style } : undefined;
|
||||
}
|
||||
if (action.type === "command") {
|
||||
const command = rewriteTelegramApprovalDecisionAlias(action.command.trim());
|
||||
const nativeCallbackData = command
|
||||
@@ -73,8 +85,11 @@ function toTelegramInlineButton(
|
||||
}
|
||||
// Reserve the full approval prefix, including malformed values, so legacy
|
||||
// plugin callbacks cannot be consumed by the approval handler.
|
||||
const normalizedCallbackValue = action.value.trim();
|
||||
const needsOpaqueEnvelope =
|
||||
Boolean(button.action) || hasTelegramApprovalCallbackPrefix(action.value);
|
||||
Boolean(button.action) ||
|
||||
hasTelegramApprovalCallbackPrefix(normalizedCallbackValue) ||
|
||||
hasTelegramQuestionCallbackPrefix(normalizedCallbackValue);
|
||||
const callbackData = sanitizeTelegramCallbackData(
|
||||
needsOpaqueEnvelope ? buildTelegramOpaqueCallbackData(action.value) : action.value,
|
||||
);
|
||||
@@ -88,7 +103,7 @@ function chunkInteractiveButtons(
|
||||
for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) {
|
||||
const row = buttons
|
||||
.slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE)
|
||||
.map(toTelegramInlineButton)
|
||||
.map((button, offset) => toTelegramInlineButton(button, i + offset))
|
||||
.filter((button): button is TelegramInlineButton => Boolean(button));
|
||||
if (row.length > 0) {
|
||||
rows.push(row);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Telegram question callback envelope tests.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTelegramQuestionCallbackData,
|
||||
parseTelegramQuestionCallbackData,
|
||||
} from "./question-callback-data.js";
|
||||
|
||||
describe("question callback data", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
|
||||
it("round-trips a compact option index within Telegram's byte limit", () => {
|
||||
const data = buildTelegramQuestionCallbackData({ questionId, optionIndex: 3 });
|
||||
|
||||
expect(data).toBe(`tgq1:${questionId}:3`);
|
||||
expect(Buffer.byteLength(data ?? "", "utf8")).toBe(43);
|
||||
expect(Buffer.byteLength(data ?? "", "utf8")).toBeLessThanOrEqual(64);
|
||||
expect(parseTelegramQuestionCallbackData(data)).toEqual({ questionId, optionIndex: 3 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
`tgq1:${questionId}:4`,
|
||||
`tgq2:${questionId}:0`,
|
||||
"tgq1:ask_short:0",
|
||||
`tgq1:${questionId}:0:extra`,
|
||||
])("rejects malformed data: %s", (data) => {
|
||||
expect(parseTelegramQuestionCallbackData(data)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// Telegram-private ask_user callback envelope.
|
||||
const TELEGRAM_QUESTION_CALLBACK_PREFIX = "tgq1:";
|
||||
const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
|
||||
const QUESTION_RECORD_ID_PATTERN = /^ask_[a-f0-9]{32}$/u;
|
||||
|
||||
export type TelegramQuestionCallback = {
|
||||
questionId: string;
|
||||
optionIndex: number;
|
||||
};
|
||||
|
||||
export function hasTelegramQuestionCallbackPrefix(data?: string | null): boolean {
|
||||
return data?.startsWith(TELEGRAM_QUESTION_CALLBACK_PREFIX) === true;
|
||||
}
|
||||
|
||||
export function buildTelegramQuestionCallbackData(
|
||||
callback: TelegramQuestionCallback,
|
||||
): string | undefined {
|
||||
if (
|
||||
!QUESTION_RECORD_ID_PATTERN.test(callback.questionId) ||
|
||||
!Number.isInteger(callback.optionIndex) ||
|
||||
callback.optionIndex < 0 ||
|
||||
callback.optionIndex > 3
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const data = `${TELEGRAM_QUESTION_CALLBACK_PREFIX}${callback.questionId}:${callback.optionIndex}`;
|
||||
return Buffer.byteLength(data, "utf8") <= TELEGRAM_CALLBACK_DATA_MAX_BYTES ? data : undefined;
|
||||
}
|
||||
|
||||
export function parseTelegramQuestionCallbackData(
|
||||
data?: string | null,
|
||||
): TelegramQuestionCallback | null {
|
||||
if (
|
||||
!hasTelegramQuestionCallbackPrefix(data) ||
|
||||
!data ||
|
||||
Buffer.byteLength(data, "utf8") > TELEGRAM_CALLBACK_DATA_MAX_BYTES
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const match = /^tgq1:(ask_[a-f0-9]{32}):([0-3])$/u.exec(data);
|
||||
return match?.[1] && match[2] ? { questionId: match[1], optionIndex: Number(match[2]) } : null;
|
||||
}
|
||||
@@ -282,6 +282,17 @@ describe("getTelegramSequentialKey", () => {
|
||||
},
|
||||
"telegram:789:approval",
|
||||
],
|
||||
[
|
||||
{
|
||||
update: {
|
||||
callback_query: {
|
||||
message: mockMessage({ chat: mockChat({ id: 321 }) }),
|
||||
data: "tgq1:ask_0123456789abcdef0123456789abcdef:2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"telegram:321:question",
|
||||
],
|
||||
[
|
||||
{
|
||||
update: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveTelegramForumThreadId,
|
||||
resolveTelegramMessageForumFlagHint,
|
||||
} from "./bot/helpers.js";
|
||||
import { parseTelegramQuestionCallbackData } from "./question-callback-data.js";
|
||||
|
||||
const TELEGRAM_READ_ONLY_STATUS_COMMAND_KEYS = new Set([
|
||||
"commands",
|
||||
@@ -179,6 +180,12 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str
|
||||
return "telegram:btw";
|
||||
}
|
||||
const callbackData = ctx.update?.callback_query?.data;
|
||||
if (parseTelegramQuestionCallbackData(callbackData)) {
|
||||
if (typeof chatId === "number") {
|
||||
return `telegram:${chatId}:question`;
|
||||
}
|
||||
return "telegram:question";
|
||||
}
|
||||
if (callbackData && parseExecApprovalCommandText(callbackData) !== null) {
|
||||
if (typeof chatId === "number") {
|
||||
return `telegram:${chatId}:approval`;
|
||||
|
||||
@@ -330,6 +330,10 @@
|
||||
"types": "./dist/plugin-sdk/approval-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/approval-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/question-gateway-runtime": {
|
||||
"types": "./dist/plugin-sdk/question-gateway-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/question-gateway-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/config-runtime": {
|
||||
"types": "./dist/plugin-sdk/config-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/config-runtime.js"
|
||||
|
||||
@@ -187,6 +187,24 @@ import {
|
||||
ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
QuestionAnswersSchema,
|
||||
QuestionGetParamsSchema,
|
||||
QuestionGetResultSchema,
|
||||
QuestionListParamsSchema,
|
||||
QuestionListResultSchema,
|
||||
QuestionOptionSchema,
|
||||
QuestionRecordSchema,
|
||||
QuestionRequestedEventSchema,
|
||||
QuestionRequestParamsSchema,
|
||||
QuestionRequestQuestionSchema,
|
||||
QuestionRequestResultSchema,
|
||||
QuestionResolvedEventSchema,
|
||||
QuestionResolveParamsSchema,
|
||||
QuestionResolveResultSchema,
|
||||
QuestionSchema,
|
||||
QuestionStatusSchema,
|
||||
QuestionWaitAnswerParamsSchema,
|
||||
QuestionWaitAnswerResultSchema,
|
||||
PluginApprovalRequestParamsSchema,
|
||||
PluginApprovalResolveParamsSchema,
|
||||
PluginCatalogEntrySchema,
|
||||
@@ -832,6 +850,18 @@ export const validateExecApprovalsSetParams = lazyCompile(ExecApprovalsSetParams
|
||||
export const validateExecApprovalGetParams = lazyCompile(ExecApprovalGetParamsSchema);
|
||||
export const validateExecApprovalRequestParams = lazyCompile(ExecApprovalRequestParamsSchema);
|
||||
export const validateExecApprovalResolveParams = lazyCompile(ExecApprovalResolveParamsSchema);
|
||||
export const validateQuestionRequestParams = lazyCompile(QuestionRequestParamsSchema);
|
||||
export const validateQuestionRequestResult = lazyCompile(QuestionRequestResultSchema);
|
||||
export const validateQuestionWaitAnswerParams = lazyCompile(QuestionWaitAnswerParamsSchema);
|
||||
export const validateQuestionWaitAnswerResult = lazyCompile(QuestionWaitAnswerResultSchema);
|
||||
export const validateQuestionResolveParams = lazyCompile(QuestionResolveParamsSchema);
|
||||
export const validateQuestionResolveResult = lazyCompile(QuestionResolveResultSchema);
|
||||
export const validateQuestionGetParams = lazyCompile(QuestionGetParamsSchema);
|
||||
export const validateQuestionGetResult = lazyCompile(QuestionGetResultSchema);
|
||||
export const validateQuestionListParams = lazyCompile(QuestionListParamsSchema);
|
||||
export const validateQuestionListResult = lazyCompile(QuestionListResultSchema);
|
||||
export const validateQuestionRequestedEvent = lazyCompile(QuestionRequestedEventSchema);
|
||||
export const validateQuestionResolvedEvent = lazyCompile(QuestionResolvedEventSchema);
|
||||
export const validatePluginApprovalRequestParams = lazyCompile(PluginApprovalRequestParamsSchema);
|
||||
export const validatePluginApprovalResolveParams = lazyCompile(PluginApprovalResolveParamsSchema);
|
||||
export const validatePluginsListParams = lazyCompile(PluginsListParamsSchema);
|
||||
@@ -1297,6 +1327,24 @@ export {
|
||||
ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
QuestionAnswersSchema,
|
||||
QuestionGetParamsSchema,
|
||||
QuestionGetResultSchema,
|
||||
QuestionListParamsSchema,
|
||||
QuestionListResultSchema,
|
||||
QuestionOptionSchema,
|
||||
QuestionRecordSchema,
|
||||
QuestionRequestedEventSchema,
|
||||
QuestionRequestParamsSchema,
|
||||
QuestionRequestQuestionSchema,
|
||||
QuestionRequestResultSchema,
|
||||
QuestionResolvedEventSchema,
|
||||
QuestionResolveParamsSchema,
|
||||
QuestionResolveResultSchema,
|
||||
QuestionSchema,
|
||||
QuestionStatusSchema,
|
||||
QuestionWaitAnswerParamsSchema,
|
||||
QuestionWaitAnswerResultSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
@@ -1697,6 +1745,24 @@ export type {
|
||||
ExecApprovalGetParams,
|
||||
ExecApprovalRequestParams,
|
||||
ExecApprovalResolveParams,
|
||||
Question,
|
||||
QuestionAnswers,
|
||||
QuestionGetParams,
|
||||
QuestionGetResult,
|
||||
QuestionListParams,
|
||||
QuestionListResult,
|
||||
QuestionOption,
|
||||
QuestionRecord,
|
||||
QuestionRequestedEvent,
|
||||
QuestionRequestParams,
|
||||
QuestionRequestQuestion,
|
||||
QuestionRequestResult,
|
||||
QuestionResolvedEvent,
|
||||
QuestionResolveParams,
|
||||
QuestionResolveResult,
|
||||
QuestionStatus,
|
||||
QuestionWaitAnswerParams,
|
||||
QuestionWaitAnswerResult,
|
||||
LogsTailParams,
|
||||
LogsTailResult,
|
||||
TerminalOpenParams,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateQuestionGetResult,
|
||||
validateQuestionListResult,
|
||||
validateQuestionRequestedEvent,
|
||||
validateQuestionRequestParams,
|
||||
validateQuestionRequestResult,
|
||||
validateQuestionResolvedEvent,
|
||||
validateQuestionResolveParams,
|
||||
validateQuestionResolveResult,
|
||||
validateQuestionWaitAnswerParams,
|
||||
validateQuestionWaitAnswerResult,
|
||||
} from "./index.js";
|
||||
|
||||
const question = {
|
||||
id: "choice",
|
||||
header: "Choice",
|
||||
question: "Which option?",
|
||||
options: [{ label: "One", description: "First" }, { label: "Two" }],
|
||||
multiSelect: false,
|
||||
isOther: true,
|
||||
isSecret: false,
|
||||
};
|
||||
const answers = { answers: { choice: { answers: ["Two"] } } };
|
||||
const pendingRecord = {
|
||||
id: "question-uuid",
|
||||
questions: [question],
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
describe("question protocol validators", () => {
|
||||
it("round-trips method params and results", () => {
|
||||
expect(
|
||||
validateQuestionRequestParams({
|
||||
id: "client-question-id",
|
||||
questions: [question],
|
||||
timeoutMs: 100,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateQuestionRequestResult({ id: "question-uuid", expiresAtMs: 2 })).toBe(true);
|
||||
expect(validateQuestionWaitAnswerParams({ id: "question-uuid", timeoutMs: 50 })).toBe(true);
|
||||
expect(validateQuestionWaitAnswerResult({ status: "pending" })).toBe(true);
|
||||
expect(validateQuestionWaitAnswerResult({ status: "answered", answers })).toBe(true);
|
||||
expect(validateQuestionResolveParams({ id: "question-uuid", answers })).toBe(true);
|
||||
expect(validateQuestionResolveParams({ id: "question-uuid", cancel: true })).toBe(true);
|
||||
expect(validateQuestionResolveResult({ status: "cancelled" })).toBe(true);
|
||||
expect(validateQuestionGetResult({ question: pendingRecord })).toBe(true);
|
||||
expect(validateQuestionListResult({ questions: [pendingRecord] })).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips requested and resolved events", () => {
|
||||
expect(validateQuestionRequestedEvent(pendingRecord)).toBe(true);
|
||||
expect(
|
||||
validateQuestionResolvedEvent({ id: "question-uuid", status: "answered", answers }),
|
||||
).toBe(true);
|
||||
expect(validateQuestionResolvedEvent({ id: "question-uuid", status: "expired" })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps records normalized while allowing request-boundary header truncation", () => {
|
||||
expect(
|
||||
validateQuestionRequestParams({
|
||||
questions: [{ ...question, header: "longer than twelve" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateQuestionRequestedEvent({
|
||||
...pendingRecord,
|
||||
questions: [{ ...question, header: "longer than twelve" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(validateQuestionRequestParams({ questions: [] })).toBe(false);
|
||||
expect(
|
||||
validateQuestionRequestParams({ questions: [question, question, question, question] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export * from "./schema/migrations.js";
|
||||
export * from "./schema/nodes.js";
|
||||
export * from "./schema/protocol-schemas.js";
|
||||
export * from "./schema/push.js";
|
||||
export * from "./schema/questions.js";
|
||||
export * from "./schema/secrets.js";
|
||||
export * from "./schema/session-placement.js";
|
||||
export * from "./schema/sessions.js";
|
||||
|
||||
@@ -369,6 +369,25 @@ import {
|
||||
import { NodeInvokeProtocolSchemas } from "./protocol-schemas-node-invoke.js";
|
||||
import { NodePresenceProtocolSchemas } from "./protocol-schemas-node-presence.js";
|
||||
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||
import {
|
||||
QuestionAnswersSchema,
|
||||
QuestionGetParamsSchema,
|
||||
QuestionGetResultSchema,
|
||||
QuestionListParamsSchema,
|
||||
QuestionListResultSchema,
|
||||
QuestionOptionSchema,
|
||||
QuestionRecordSchema,
|
||||
QuestionRequestParamsSchema,
|
||||
QuestionRequestQuestionSchema,
|
||||
QuestionRequestResultSchema,
|
||||
QuestionResolvedEventSchema,
|
||||
QuestionResolveParamsSchema,
|
||||
QuestionResolveResultSchema,
|
||||
QuestionSchema,
|
||||
QuestionStatusSchema,
|
||||
QuestionWaitAnswerParamsSchema,
|
||||
QuestionWaitAnswerResultSchema,
|
||||
} from "./questions.js";
|
||||
import {
|
||||
SecretsReloadParamsSchema,
|
||||
SecretsResolveAssignmentSchema,
|
||||
@@ -938,6 +957,25 @@ export const ProtocolSchemas = {
|
||||
ExecApprovalGetParams: ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||
QuestionOption: QuestionOptionSchema,
|
||||
Question: QuestionSchema,
|
||||
QuestionRequestQuestion: QuestionRequestQuestionSchema,
|
||||
QuestionAnswers: QuestionAnswersSchema,
|
||||
QuestionStatus: QuestionStatusSchema,
|
||||
QuestionRecord: QuestionRecordSchema,
|
||||
QuestionRequestParams: QuestionRequestParamsSchema,
|
||||
QuestionRequestResult: QuestionRequestResultSchema,
|
||||
QuestionWaitAnswerParams: QuestionWaitAnswerParamsSchema,
|
||||
QuestionWaitAnswerResult: QuestionWaitAnswerResultSchema,
|
||||
QuestionResolveParams: QuestionResolveParamsSchema,
|
||||
QuestionResolveResult: QuestionResolveResultSchema,
|
||||
QuestionGetParams: QuestionGetParamsSchema,
|
||||
QuestionGetResult: QuestionGetResultSchema,
|
||||
QuestionListParams: QuestionListParamsSchema,
|
||||
QuestionListResult: QuestionListResultSchema,
|
||||
// QuestionRequestedEvent is a TS-only alias of QuestionRecord; registering both
|
||||
// names makes native codegen reference a type it never emits.
|
||||
QuestionResolvedEvent: QuestionResolvedEventSchema,
|
||||
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,
|
||||
PluginApprovalResolveParams: PluginApprovalResolveParamsSchema,
|
||||
PluginCatalogClawHubInstall: PluginCatalogClawHubInstallSchema,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Gateway Protocol schema module defines transient operator questions.
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const QuestionIdSchema = Type.String({ pattern: "^[a-z][a-z0-9_]*$" });
|
||||
const QuestionHeaderSchema = Type.String({ maxLength: 12 });
|
||||
|
||||
export const QuestionOptionSchema = closedObject({
|
||||
label: NonEmptyString,
|
||||
description: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
const QuestionInputFields = {
|
||||
id: QuestionIdSchema,
|
||||
header: Type.String(),
|
||||
question: NonEmptyString,
|
||||
options: Type.Array(QuestionOptionSchema, { maxItems: 4 }),
|
||||
multiSelect: Type.Optional(Type.Boolean()),
|
||||
isOther: Type.Optional(Type.Boolean()),
|
||||
isSecret: Type.Optional(Type.Boolean()),
|
||||
};
|
||||
|
||||
/** Unnormalized question accepted by question.request. */
|
||||
export const QuestionRequestQuestionSchema = closedObject(QuestionInputFields);
|
||||
|
||||
const QuestionFields = {
|
||||
...QuestionInputFields,
|
||||
header: QuestionHeaderSchema,
|
||||
};
|
||||
|
||||
/** Canonical normalized question shown to an operator. */
|
||||
export const QuestionSchema = closedObject(QuestionFields);
|
||||
|
||||
export const QuestionAnswersSchema = closedObject({
|
||||
answers: Type.Record(QuestionIdSchema, closedObject({ answers: Type.Array(Type.String()) })),
|
||||
});
|
||||
|
||||
export const QuestionStatusSchema = Type.Union([
|
||||
Type.Literal("pending"),
|
||||
Type.Literal("answered"),
|
||||
Type.Literal("cancelled"),
|
||||
Type.Literal("expired"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* One pending or recently resolved transient question request. Flat object with
|
||||
* optional terminal fields (exec-approval record precedent): native protocol
|
||||
* codegen cannot emit per-status object unions, and the manager owns the
|
||||
* status/answers invariant (answers present only when status is "answered").
|
||||
*/
|
||||
export const QuestionRecordSchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: 3 }),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
createdAtMs: Type.Integer({ minimum: 0 }),
|
||||
expiresAtMs: Type.Integer({ minimum: 0 }),
|
||||
status: QuestionStatusSchema,
|
||||
answers: Type.Optional(QuestionAnswersSchema),
|
||||
resolvedBy: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
export const QuestionRequestParamsSchema = closedObject({
|
||||
id: Type.Optional(NonEmptyString),
|
||||
questions: Type.Array(QuestionRequestQuestionSchema, { minItems: 1, maxItems: 3 }),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
});
|
||||
|
||||
export const QuestionRequestResultSchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
expiresAtMs: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
|
||||
export const QuestionWaitAnswerParamsSchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
});
|
||||
|
||||
export const QuestionWaitAnswerResultSchema = Type.Union([
|
||||
closedObject({ status: Type.Literal("pending") }),
|
||||
closedObject({ status: Type.Literal("answered"), answers: QuestionAnswersSchema }),
|
||||
closedObject({ status: Type.Literal("cancelled") }),
|
||||
closedObject({ status: Type.Literal("expired") }),
|
||||
]);
|
||||
|
||||
export const QuestionResolveParamsSchema = Type.Union([
|
||||
closedObject({
|
||||
id: NonEmptyString,
|
||||
answers: QuestionAnswersSchema,
|
||||
resolvedBy: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
closedObject({
|
||||
id: NonEmptyString,
|
||||
cancel: Type.Literal(true),
|
||||
resolvedBy: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const QuestionResolveResultSchema = Type.Union([
|
||||
closedObject({ status: Type.Literal("answered"), answers: QuestionAnswersSchema }),
|
||||
closedObject({ status: Type.Literal("cancelled") }),
|
||||
]);
|
||||
|
||||
export const QuestionGetParamsSchema = closedObject({ id: NonEmptyString });
|
||||
export const QuestionGetResultSchema = closedObject({ question: QuestionRecordSchema });
|
||||
export const QuestionListParamsSchema = closedObject({});
|
||||
export const QuestionListResultSchema = closedObject({
|
||||
questions: Type.Array(QuestionRecordSchema),
|
||||
});
|
||||
|
||||
export const QuestionRequestedEventSchema = QuestionRecordSchema;
|
||||
export const QuestionResolvedEventSchema = Type.Union([
|
||||
closedObject({
|
||||
id: NonEmptyString,
|
||||
status: Type.Literal("answered"),
|
||||
answers: QuestionAnswersSchema,
|
||||
}),
|
||||
closedObject({ id: NonEmptyString, status: Type.Literal("cancelled") }),
|
||||
closedObject({ id: NonEmptyString, status: Type.Literal("expired") }),
|
||||
]);
|
||||
|
||||
export type QuestionOption = Static<typeof QuestionOptionSchema>;
|
||||
export type Question = Static<typeof QuestionSchema>;
|
||||
export type QuestionRequestQuestion = Static<typeof QuestionRequestQuestionSchema>;
|
||||
export type QuestionAnswers = Static<typeof QuestionAnswersSchema>;
|
||||
export type QuestionStatus = Static<typeof QuestionStatusSchema>;
|
||||
export type QuestionRecord = Static<typeof QuestionRecordSchema>;
|
||||
export type QuestionRequestParams = Static<typeof QuestionRequestParamsSchema>;
|
||||
export type QuestionRequestResult = Static<typeof QuestionRequestResultSchema>;
|
||||
export type QuestionWaitAnswerParams = Static<typeof QuestionWaitAnswerParamsSchema>;
|
||||
export type QuestionWaitAnswerResult = Static<typeof QuestionWaitAnswerResultSchema>;
|
||||
export type QuestionResolveParams = Static<typeof QuestionResolveParamsSchema>;
|
||||
export type QuestionResolveResult = Static<typeof QuestionResolveResultSchema>;
|
||||
export type QuestionGetParams = Static<typeof QuestionGetParamsSchema>;
|
||||
export type QuestionGetResult = Static<typeof QuestionGetResultSchema>;
|
||||
export type QuestionListParams = Static<typeof QuestionListParamsSchema>;
|
||||
export type QuestionListResult = Static<typeof QuestionListResultSchema>;
|
||||
export type QuestionRequestedEvent = Static<typeof QuestionRequestedEventSchema>;
|
||||
export type QuestionResolvedEvent = Static<typeof QuestionResolvedEventSchema>;
|
||||
@@ -32,6 +32,7 @@
|
||||
"approval-reaction-runtime",
|
||||
"approval-reply-runtime",
|
||||
"approval-runtime",
|
||||
"question-gateway-runtime",
|
||||
"config-runtime",
|
||||
"config-contracts",
|
||||
"config-types",
|
||||
|
||||
@@ -214,7 +214,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// Registry sweep: 77 packages, zero fetch failures; retired dead channel-ingress facade.
|
||||
// +1: speech-settings keeps agent prompt imports off the synthesis/runtime graph.
|
||||
// +1: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
||||
330,
|
||||
// +1: question-gateway-runtime resolves ask_user choices for channel plugins.
|
||||
331,
|
||||
env,
|
||||
),
|
||||
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
|
||||
@@ -262,7 +263,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// Harvest: retired dual-field plan payload builder -1.
|
||||
// +23: core channel, envelope, direct-DM, feedback, legacy-payload, and memory contracts.
|
||||
// +81: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
||||
8149,
|
||||
// +3: question-gateway-runtime resolver plus request/result types.
|
||||
8152,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -296,7 +298,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// Harvest: retired dual-field plan payload builder -1.
|
||||
// +13: core channel, envelope, direct-DM, feedback, legacy-payload, and memory operations.
|
||||
// +32: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
||||
4533,
|
||||
// +1: question-gateway-runtime resolver.
|
||||
4534,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced.",
|
||||
"plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.",
|
||||
"plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.",
|
||||
"question.requested": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.",
|
||||
"question.resolved": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.",
|
||||
"openclaw.approval.requested": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.",
|
||||
"openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.",
|
||||
"terminal.data": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
|
||||
@@ -44,6 +46,8 @@
|
||||
"voicewake.routing.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet.",
|
||||
"plugin.approval.requested": "Plugin approval prompts are not implemented on Android.",
|
||||
"plugin.approval.resolved": "Plugin approval prompts are not implemented on Android.",
|
||||
"question.requested": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.",
|
||||
"question.resolved": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.",
|
||||
"openclaw.approval.requested": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.",
|
||||
"openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.",
|
||||
"terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
|
||||
|
||||
@@ -17,6 +17,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
|
||||
{ name: "exec", family: "shell" },
|
||||
{ name: "process", family: "shell" },
|
||||
{ name: "agents_list", family: "openclaw" },
|
||||
{ name: "ask_user", family: "openclaw" },
|
||||
{ name: "openclaw", family: "openclaw" },
|
||||
{ name: "computer", family: "openclaw" },
|
||||
{ name: "conversations_list", family: "openclaw" },
|
||||
|
||||
@@ -353,7 +353,12 @@ export function prepareEmbeddedAttemptStream(input: {
|
||||
if (options?.steeringMode) {
|
||||
input.activeSession.agent.steeringMode = options.steeringMode;
|
||||
}
|
||||
await steerActiveSessionWithOptionalDeliveryWait(input.activeSession, text, options);
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
input.activeSession,
|
||||
text,
|
||||
options,
|
||||
attempt.sessionKey,
|
||||
);
|
||||
},
|
||||
isStreaming: () => input.activeSession.isStreaming,
|
||||
isStopped: () =>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import { toErrorObject } from "../../../infra/errors.js";
|
||||
import type { ImageContent } from "../../../llm/types.js";
|
||||
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js";
|
||||
import {
|
||||
cancelPendingAskUserForSession,
|
||||
claimPendingAskUserAnswer,
|
||||
} from "../../tools/ask-user-tool.js";
|
||||
import { log } from "../logger.js";
|
||||
import type { EmbeddedAgentQueueMessageOptions } from "../run-state.js";
|
||||
|
||||
/**
|
||||
* Minimal active-session surface needed to steer a running attempt and observe
|
||||
@@ -230,15 +235,33 @@ async function steerAndWaitForTranscriptCommit(
|
||||
export async function steerActiveSessionWithOptionalDeliveryWait(
|
||||
activeSession: EmbeddedAgentActiveSessionSteerTarget,
|
||||
text: string,
|
||||
options:
|
||||
| {
|
||||
deliveryTimeoutMs?: number;
|
||||
images?: ImageContent[];
|
||||
waitForTranscriptCommit?: boolean;
|
||||
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
|
||||
}
|
||||
| undefined,
|
||||
options: EmbeddedAgentQueueMessageOptions | undefined,
|
||||
sessionKey?: string,
|
||||
): Promise<void> {
|
||||
const isInboundUserMessage = options?.isInboundUserMessage === true;
|
||||
const isPlainTextAnswer = !options?.images?.length;
|
||||
if (isInboundUserMessage && !isPlainTextAnswer) {
|
||||
try {
|
||||
await cancelPendingAskUserForSession({ sessionKey, resolvedBy: "image-reply" });
|
||||
} catch (error) {
|
||||
log.warn(`failed to cancel ask_user before image steering: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
isInboundUserMessage &&
|
||||
isPlainTextAnswer &&
|
||||
(await claimPendingAskUserAnswer({
|
||||
sessionKey,
|
||||
text,
|
||||
persist: options.userTurnTranscriptRecorder
|
||||
? async () => {
|
||||
await options.userTurnTranscriptRecorder?.persistApproved();
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (options?.waitForTranscriptCommit !== true) {
|
||||
if (options?.userTurnTranscriptRecorder) {
|
||||
await activeSession.steer(text, options.images, options.userTurnTranscriptRecorder);
|
||||
|
||||
@@ -29,11 +29,62 @@ import type {
|
||||
ToolCallSummary,
|
||||
ToolHandlerContext,
|
||||
} from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import {
|
||||
createAskUserTool,
|
||||
normalizeAskUserParams,
|
||||
reserveAskUserPromptDelivery,
|
||||
} from "./tools/ask-user-tool.js";
|
||||
import { resetPendingAskUserQuestionsForTest } from "./tools/ask-user-tool.test-support.js";
|
||||
|
||||
type ToolExecutionStartEvent = Extract<AgentEvent, { type: "tool_execution_start" }>;
|
||||
type ToolExecutionEndEvent = Extract<AgentEvent, { type: "tool_execution_end" }>;
|
||||
type PayloadToolMetas = Parameters<typeof buildEmbeddedRunPayloads>[0]["toolMetas"];
|
||||
|
||||
const pendingAskUserFinishes = new Set<() => Promise<void>>();
|
||||
|
||||
async function activateAskUserPrompt(toolCallId: string, args: unknown) {
|
||||
let questionId: string | undefined;
|
||||
let resolveAnswer: ((value: { status: "cancelled" }) => void) | undefined;
|
||||
const tool = createAskUserTool({
|
||||
sessionKey: "agent:unit-session",
|
||||
gatewayCall: async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
if (!params || typeof params !== "object" || !("id" in params)) {
|
||||
throw new Error("question.request params missing id");
|
||||
}
|
||||
questionId = String(params.id);
|
||||
return { id: questionId };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
resolveAnswer = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
},
|
||||
});
|
||||
const pending = tool.execute(toolCallId, args);
|
||||
let finished = false;
|
||||
const finish = async () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
await vi.waitFor(() => expect(resolveAnswer).toBeTypeOf("function"));
|
||||
resolveAnswer?.({ status: "cancelled" });
|
||||
await pending;
|
||||
pendingAskUserFinishes.delete(finish);
|
||||
};
|
||||
pendingAskUserFinishes.add(finish);
|
||||
await vi.waitFor(() => expect(questionId).toBeTypeOf("string"));
|
||||
return { questionId: questionId!, finish };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...pendingAskUserFinishes].map((finish) => finish()));
|
||||
resetPendingAskUserQuestionsForTest();
|
||||
});
|
||||
|
||||
const beforeToolCallTesting = { adjustedParamsByToolCallId, buildAdjustedParamsKey };
|
||||
|
||||
function createTestContext(): {
|
||||
@@ -247,6 +298,311 @@ function requireSingleMessagingTarget(ctx: ToolHandlerContext) {
|
||||
}
|
||||
|
||||
describe("handleToolExecutionStart read path checks", () => {
|
||||
it("delivers a numbered ask_user prompt with question id association", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
const args = {
|
||||
questions: [
|
||||
{
|
||||
id: "deploy_target",
|
||||
header: "Target",
|
||||
question: "Where should this deploy?",
|
||||
options: [
|
||||
{ label: "Staging (Recommended)", description: "Safer default" },
|
||||
{ label: "Production" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-call-1",
|
||||
args,
|
||||
});
|
||||
const activation = await activateAskUserPrompt("ask-call-1", args);
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
const { questionId } = activation;
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
text: [
|
||||
"Question for you:",
|
||||
"",
|
||||
"Target",
|
||||
"Where should this deploy?",
|
||||
"1. Staging (Recommended) - Safer default",
|
||||
"2. Production",
|
||||
"Other: reply with your own answer.",
|
||||
"",
|
||||
"Reply with the number, the option text, or your own answer.",
|
||||
].join("\n"),
|
||||
channelData: {
|
||||
askUser: {
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
presentationTextMode: "fallback",
|
||||
presentation: {
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Where should this deploy?",
|
||||
"",
|
||||
"- Staging (Recommended): Safer default",
|
||||
"- Production",
|
||||
"",
|
||||
"Tap an option, or reply with the option text or your own answer.",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Staging (Recommended)",
|
||||
action: {
|
||||
type: "question",
|
||||
questionId,
|
||||
optionValue: "Staging (Recommended)",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Production",
|
||||
action: {
|
||||
type: "question",
|
||||
questionId,
|
||||
optionValue: "Production",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "multi-question",
|
||||
questions: [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
},
|
||||
{
|
||||
id: "region",
|
||||
header: "Region",
|
||||
question: "Which region?",
|
||||
options: [{ label: "EU" }, { label: "US" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "multi-select",
|
||||
questions: [
|
||||
{
|
||||
id: "targets",
|
||||
header: "Targets",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
])("keeps $name ask_user prompts text-only", async ({ questions }) => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
const toolCallId = `ask-${questions[0]?.id ?? "unknown"}`;
|
||||
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId,
|
||||
args: { questions },
|
||||
});
|
||||
const activation = await activateAskUserPrompt(toolCallId, { questions });
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
|
||||
const payload = onToolResult.mock.calls[0]?.[0];
|
||||
expect(payload?.text).toContain("Reply with the number, the option text, or your own answer.");
|
||||
expect(payload).not.toHaveProperty("presentation");
|
||||
expect(payload).not.toHaveProperty("presentationTextMode");
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it("reserves ask_user before awaiting block-reply flush", async () => {
|
||||
const { ctx, onBlockReplyFlush } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
let releaseFlush: (() => void) | undefined;
|
||||
onBlockReplyFlush.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFlush = resolve;
|
||||
}),
|
||||
);
|
||||
const args = {
|
||||
questions: [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pending = handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-flush",
|
||||
args,
|
||||
});
|
||||
const activation = await activateAskUserPrompt("ask-flush", args);
|
||||
await Promise.resolve();
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
|
||||
releaseFlush?.();
|
||||
await pending;
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it.each(["buffer", "callback"] as const)(
|
||||
"releases ask_user reservation when the %s flush throws synchronously",
|
||||
(flushKind) => {
|
||||
const { ctx, onBlockReplyFlush } = createTestContext();
|
||||
ctx.params.onToolResult = vi.fn();
|
||||
const failure = new Error("flush failed");
|
||||
if (flushKind === "buffer") {
|
||||
vi.mocked(ctx.flushBlockReplyBuffer).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
} else {
|
||||
onBlockReplyFlush.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
}
|
||||
const args = {
|
||||
questions: [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: `ask-${flushKind}-failure`,
|
||||
args,
|
||||
}),
|
||||
).toThrow(failure);
|
||||
expect(
|
||||
reserveAskUserPromptDelivery({
|
||||
toolCallId: `ask-${flushKind}-retry`,
|
||||
sessionKey: "agent:unit-session",
|
||||
questions: normalizeAskUserParams(args).questions,
|
||||
}),
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("delivers only the ask_user prompt that reserved the session slot", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
const args = {
|
||||
questions: [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-first",
|
||||
args,
|
||||
});
|
||||
const activation = await activateAskUserPrompt("ask-first", args);
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-second",
|
||||
args,
|
||||
});
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelData: {
|
||||
askUser: {
|
||||
questionId: activation.questionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it("releases an undelivered ask_user reservation when execution is rejected", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
const args = {
|
||||
questions: [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
question: "Where next?",
|
||||
options: [{ label: "Staging" }, { label: "Production" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-denied",
|
||||
args,
|
||||
});
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-denied",
|
||||
isError: true,
|
||||
result: { content: [{ type: "text", text: "denied" }] },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
|
||||
await handleToolExecutionStart(ctx, {
|
||||
type: "tool_execution_start",
|
||||
toolName: "ask_user",
|
||||
toolCallId: "ask-after-denial",
|
||||
args,
|
||||
});
|
||||
const activation = await activateAskUserPrompt("ask-after-denial", args);
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it("emits trace-only tool start diagnostics when trace logging is enabled", async () => {
|
||||
const { ctx, trace, isEnabled, warn } = createTestContext();
|
||||
isEnabled.mockImplementation((level: string) => level === "trace");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { QuestionRequestQuestion } from "../../packages/gateway-protocol/src/schema/questions.js";
|
||||
import {
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
normalizeHeartbeatToolResponse,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
parseInteractiveParam,
|
||||
parseJsonMessageParam,
|
||||
} from "../infra/outbound/message-action-params.js";
|
||||
import { hasReplyPayloadContent } from "../interactive/payload.js";
|
||||
import { hasReplyPayloadContent, type MessagePresentation } from "../interactive/payload.js";
|
||||
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { hasTopLevelShellControlOperator, splitShellArgs } from "../utils/shell-argv.js";
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
import { inferToolMetaFromArgs } from "./embedded-agent-utils.js";
|
||||
import { parseExecApprovalResultText } from "./exec-approval-result.js";
|
||||
import { formatAgentHarnessUserInputPrompt } from "./harness/user-input-bridge.js";
|
||||
import type { AgentEvent } from "./runtime/index.js";
|
||||
import {
|
||||
createToolValidationErrorSummary,
|
||||
@@ -98,6 +100,13 @@ import { buildToolMutationState } from "./tool-mutation.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
import { readToolResultDetails } from "./tool-result-error.js";
|
||||
import { createToolTerminalObserver } from "./tool-terminal-outcome.js";
|
||||
import {
|
||||
cancelAskUserPromptDelivery,
|
||||
normalizeAskUserParams,
|
||||
reserveAskUserPromptDelivery,
|
||||
settleAskUserPromptDelivery,
|
||||
waitForAskUserPromptReady,
|
||||
} from "./tools/ask-user-tool.js";
|
||||
|
||||
type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js");
|
||||
type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js");
|
||||
@@ -111,6 +120,45 @@ const execApprovalReplyModuleLoader = createLazyImportLoader<ExecApprovalReplyMo
|
||||
const hookRunnerGlobalModuleLoader = createLazyImportLoader<HookRunnerGlobalModule>(
|
||||
() => import("../plugins/hook-runner-global.js"),
|
||||
);
|
||||
|
||||
function buildAskUserQuestionPresentation(params: {
|
||||
questionId: string;
|
||||
questions: QuestionRequestQuestion[];
|
||||
}): MessagePresentation | undefined {
|
||||
// Button taps resolve atomically, so v1 keeps multi-question records text-only.
|
||||
if (params.questions.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const [question] = params.questions;
|
||||
if (!question || question.multiSelect || question.isSecret || question.options.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const presentationText = [
|
||||
question.question,
|
||||
"",
|
||||
...question.options.map(
|
||||
(option) => `- ${option.label}${option.description ? `: ${option.description}` : ""}`,
|
||||
),
|
||||
"",
|
||||
"Tap an option, or reply with the option text or your own answer.",
|
||||
].join("\n");
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "text", text: presentationText },
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
action: {
|
||||
type: "question",
|
||||
questionId: params.questionId,
|
||||
optionValue: option.label,
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const fallbackToolTerminalObservers = new WeakMap<
|
||||
ToolHandlerContext["state"],
|
||||
ReturnType<typeof createToolTerminalObserver>
|
||||
@@ -144,6 +192,24 @@ function readUpdatePlanResult(
|
||||
return { ...(explanation ? { explanation } : {}), steps };
|
||||
}
|
||||
|
||||
function buildAskUserPromptPayload(
|
||||
toolCallId: string,
|
||||
sessionKey: string | undefined,
|
||||
args: unknown,
|
||||
) {
|
||||
try {
|
||||
const { questions } = normalizeAskUserParams(args);
|
||||
const reservation = reserveAskUserPromptDelivery({ toolCallId, sessionKey, questions });
|
||||
if (!reservation) {
|
||||
return undefined;
|
||||
}
|
||||
return reservation;
|
||||
} catch {
|
||||
// Argument validation owns malformed calls; do not deliver an unusable prompt first.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isMiddlewareToolResultError(result: unknown): boolean {
|
||||
if (!result || typeof result !== "object") {
|
||||
return false;
|
||||
@@ -943,21 +1009,40 @@ export function handleToolExecutionStart(
|
||||
hideFromChannelProgress?: boolean;
|
||||
},
|
||||
): void | Promise<void> {
|
||||
const continueAfterBlockReplyFlush = (): void | Promise<void> => {
|
||||
const onBlockReplyFlushResult = ctx.params.onBlockReplyFlush?.({
|
||||
reason: "tool_start",
|
||||
assistantMessageIndex: ctx.state.assistantMessageIndex,
|
||||
});
|
||||
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
|
||||
return onBlockReplyFlushResult.then(() => {
|
||||
continueToolExecutionStart();
|
||||
});
|
||||
const startToolName = normalizeToolName(evt.toolName);
|
||||
const askUserPromptReservation =
|
||||
startToolName === "ask_user" && ctx.params.onToolResult
|
||||
? buildAskUserPromptPayload(evt.toolCallId, ctx.params.sessionKey, evt.args)
|
||||
: undefined;
|
||||
const cancelAskUserPromptReservation = () => {
|
||||
if (askUserPromptReservation) {
|
||||
cancelAskUserPromptDelivery(evt.toolCallId, ctx.params.sessionKey);
|
||||
}
|
||||
continueToolExecutionStart();
|
||||
return undefined;
|
||||
};
|
||||
const continueAfterBlockReplyFlush = (): void | Promise<void> => {
|
||||
let onBlockReplyFlushResult: void | Promise<void>;
|
||||
try {
|
||||
onBlockReplyFlushResult = ctx.params.onBlockReplyFlush?.({
|
||||
reason: "tool_start",
|
||||
assistantMessageIndex: ctx.state.assistantMessageIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
cancelAskUserPromptReservation();
|
||||
throw error;
|
||||
}
|
||||
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
|
||||
return onBlockReplyFlushResult.then(
|
||||
() => continueToolExecutionStart(),
|
||||
(error: unknown) => {
|
||||
cancelAskUserPromptReservation();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
return continueToolExecutionStart();
|
||||
};
|
||||
|
||||
const continueToolExecutionStart = () => {
|
||||
const continueToolExecutionStart = (): void | Promise<void> => {
|
||||
const rawToolName = evt.toolName;
|
||||
const toolName = normalizeToolName(rawToolName);
|
||||
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
|
||||
@@ -1165,12 +1250,53 @@ export function handleToolExecutionStart(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === "ask_user" && ctx.params.onToolResult) {
|
||||
const payload = askUserPromptReservation;
|
||||
if (payload) {
|
||||
const questionId = payload.questionId;
|
||||
void waitForAskUserPromptReady(questionId)
|
||||
.then((questions) => {
|
||||
if (!questions) {
|
||||
return;
|
||||
}
|
||||
const prompt = formatAgentHarnessUserInputPrompt(questions, {
|
||||
intro: "Question for you:",
|
||||
});
|
||||
const presentation = buildAskUserQuestionPresentation({ questionId, questions });
|
||||
return ctx.params.onToolResult?.({
|
||||
text: `${prompt}\n\nReply with the number, the option text, or your own answer.`,
|
||||
...(presentation ? { presentation, presentationTextMode: "fallback" as const } : {}),
|
||||
channelData: { askUser: { questionId } },
|
||||
});
|
||||
})
|
||||
.then(
|
||||
() => settleAskUserPromptDelivery(questionId),
|
||||
(error: unknown) => {
|
||||
settleAskUserPromptDelivery(questionId, error);
|
||||
ctx.log.warn(`failed to deliver ask_user prompt: ${String(error)}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Flush pending block replies to preserve message boundaries before tool execution.
|
||||
const flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer();
|
||||
let flushBlockReplyBufferResult: void | Promise<void>;
|
||||
try {
|
||||
flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer();
|
||||
} catch (error) {
|
||||
cancelAskUserPromptReservation();
|
||||
throw error;
|
||||
}
|
||||
if (isPromiseLike<void>(flushBlockReplyBufferResult)) {
|
||||
return flushBlockReplyBufferResult.then(() => continueAfterBlockReplyFlush());
|
||||
return flushBlockReplyBufferResult.then(
|
||||
() => continueAfterBlockReplyFlush(),
|
||||
(error: unknown) => {
|
||||
cancelAskUserPromptReservation();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
return continueAfterBlockReplyFlush();
|
||||
}
|
||||
@@ -1280,6 +1406,9 @@ export async function handleToolExecutionEnd(
|
||||
const toolName = normalizeToolName(rawToolName);
|
||||
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
|
||||
const toolCallId = evt.toolCallId;
|
||||
if (toolName === "ask_user") {
|
||||
cancelAskUserPromptDelivery(toolCallId, ctx.params.sessionKey);
|
||||
}
|
||||
const runId = ctx.params.runId;
|
||||
const isError = evt.isError;
|
||||
const result = evt.result;
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AgentHarnessUserInputQuestion = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
multiSelect?: boolean;
|
||||
isOther?: boolean;
|
||||
isSecret?: boolean;
|
||||
options?: readonly AgentHarnessUserInputOption[] | null;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isPrimaryBootstrapRun } from "./bootstrap-routing.js";
|
||||
import { isToolAllowedByPolicyName } from "./tool-policy-match.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
@@ -51,3 +52,20 @@ export function shouldIncludeUpdatePlanToolForOpenClawTools(params: {
|
||||
isToolAllowedByPolicyName("update_plan", { deny })
|
||||
);
|
||||
}
|
||||
|
||||
/** Includes ask_user only on a primary session and when normal deny policy permits it. */
|
||||
export function shouldIncludeAskUserToolForOpenClawTools(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
pluginToolDenylist?: string[];
|
||||
}): boolean {
|
||||
const sessionKey = params.agentSessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const deny = uniqueStrings([
|
||||
...(params.config?.tools?.deny ?? []),
|
||||
...(params.pluginToolDenylist ?? []),
|
||||
]);
|
||||
return isPrimaryBootstrapRun(sessionKey) && isToolAllowedByPolicyName("ask_user", { deny });
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js";
|
||||
import {
|
||||
collectPresentOpenClawTools,
|
||||
shouldIncludeAskUserToolForOpenClawTools,
|
||||
shouldIncludeUpdatePlanToolForOpenClawTools,
|
||||
} from "./openclaw-tools.registration.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
@@ -42,6 +43,7 @@ import type { SpawnedToolContext } from "./spawned-context.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.js";
|
||||
import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js";
|
||||
import { createAgentsListTool } from "./tools/agents-list-tool.js";
|
||||
import { createAskUserTool } from "./tools/ask-user-tool.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createComputerTool } from "./tools/computer-tool.js";
|
||||
import {
|
||||
@@ -453,6 +455,13 @@ export function createOpenClawTools(
|
||||
pluginToolAllowlist: options?.pluginToolAllowlist,
|
||||
pluginToolDenylist: options?.pluginToolDenylist,
|
||||
});
|
||||
// isEmbeddedMode() marks the TUI-embedded host, not the embedded agent runner;
|
||||
// gating on it would hide ask_user from every normal gateway run.
|
||||
const includeAskUserTool = shouldIncludeAskUserToolForOpenClawTools({
|
||||
config: resolvedConfig,
|
||||
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
|
||||
pluginToolDenylist: options?.pluginToolDenylist,
|
||||
});
|
||||
const includeTranscriptsTool = resolveTranscriptsConfig(resolvedConfig?.transcripts).enabled;
|
||||
const tools: AnyAgentTool[] = [
|
||||
...(embedded
|
||||
@@ -560,6 +569,14 @@ export function createOpenClawTools(
|
||||
}),
|
||||
]),
|
||||
...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []),
|
||||
...(includeAskUserTool
|
||||
? [
|
||||
createAskUserTool({
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: options?.runSessionKey ?? options?.agentSessionKey,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createSessionsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
sandboxed: options?.sandboxed,
|
||||
|
||||
@@ -5,7 +5,10 @@ import { setEmbeddedMode } from "../infra/embedded-mode.js";
|
||||
import { isToolWrappedWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js";
|
||||
import { resolveCoreToolFactoryFamily } from "./core-tool-factory-descriptors.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import { shouldIncludeUpdatePlanToolForOpenClawTools } from "./openclaw-tools.registration.js";
|
||||
import {
|
||||
shouldIncludeAskUserToolForOpenClawTools,
|
||||
shouldIncludeUpdatePlanToolForOpenClawTools,
|
||||
} from "./openclaw-tools.registration.js";
|
||||
import { createUpdatePlanTool } from "./tools/update-plan-tool.js";
|
||||
|
||||
type UpdatePlanGatingParams = Parameters<typeof shouldIncludeUpdatePlanToolForOpenClawTools>[0];
|
||||
@@ -82,9 +85,47 @@ describe("openclaw-tools update_plan gating", () => {
|
||||
};
|
||||
|
||||
expect(defaultTools).toContain("update_plan");
|
||||
expect(defaultTools).not.toContain("ask_user");
|
||||
expect(shouldIncludeUpdatePlanToolForOpenClawTools(emptyAllowlistParams)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps ask_user on primary sessions and excludes spawned worker sessions", () => {
|
||||
expect(shouldIncludeAskUserToolForOpenClawTools({})).toBe(false);
|
||||
expect(shouldIncludeAskUserToolForOpenClawTools({ agentSessionKey: "agent:main:main" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldIncludeAskUserToolForOpenClawTools({
|
||||
agentSessionKey: "agent:main:subagent:worker",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIncludeAskUserToolForOpenClawTools({ agentSessionKey: "agent:main:acp:worker" }),
|
||||
).toBe(false);
|
||||
// ask_user must not depend on the TUI embedded-host flag; normal gateway
|
||||
// runs are the primary consumer.
|
||||
expect(
|
||||
createFastToolNames({
|
||||
config: {} as OpenClawConfig,
|
||||
runSessionKey: "agent:main:non-embedded",
|
||||
}),
|
||||
).toContain("ask_user");
|
||||
setEmbeddedMode(true);
|
||||
|
||||
expect(
|
||||
createFastToolNames({
|
||||
config: {} as OpenClawConfig,
|
||||
agentSessionKey: "agent:main:subagent:worker",
|
||||
}),
|
||||
).not.toContain("ask_user");
|
||||
expect(
|
||||
createFastToolNames({
|
||||
config: {} as OpenClawConfig,
|
||||
runSessionKey: "agent:main:run",
|
||||
}),
|
||||
).toContain("ask_user");
|
||||
});
|
||||
|
||||
it("wraps constructed tools with before-tool-call hooks by default", () => {
|
||||
const tools = createOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
|
||||
@@ -112,6 +112,11 @@ type AgentRuntimeMessagePresentationAction =
|
||||
approvalKind: "exec" | "plugin";
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
}
|
||||
| {
|
||||
type: "question";
|
||||
questionId: string;
|
||||
optionValue: string;
|
||||
}
|
||||
| {
|
||||
type: "url";
|
||||
url: string;
|
||||
@@ -264,6 +269,7 @@ type AgentRuntimeReplyPayload = {
|
||||
trustedLocalMedia?: boolean;
|
||||
sensitiveMedia?: boolean;
|
||||
presentation?: AgentRuntimeMessagePresentation;
|
||||
presentationTextMode?: "fallback";
|
||||
delivery?: AgentRuntimeReplyPayloadDelivery;
|
||||
/**
|
||||
* @deprecated Use presentation.
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn subagent or ACP sessio
|
||||
export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn subagent session.";
|
||||
export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status/model/usage.";
|
||||
export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track short work plan.";
|
||||
export const ASK_USER_TOOL_DISPLAY_SUMMARY = "Ask the user and wait for an answer.";
|
||||
export const SPAWN_TASK_TOOL_DISPLAY_SUMMARY = "Suggest follow-up work for operator approval.";
|
||||
export const DISMISS_TASK_TOOL_DISPLAY_SUMMARY = "Withdraw a pending task suggestion.";
|
||||
|
||||
@@ -98,3 +99,14 @@ export function describeSessionStatusTool(): string {
|
||||
export function describeUpdatePlanTool(): string {
|
||||
return "Use for multi-step work. Send the full list each call; keep statuses current and exactly one `in_progress` until done.";
|
||||
}
|
||||
|
||||
/** Describes the ask_user tool and its decision-only use policy. */
|
||||
export function describeAskUserTool(): string {
|
||||
return [
|
||||
"Ask the human user 1-3 structured questions and wait for their answer.",
|
||||
"Use only when blocked on a decision genuinely theirs that cannot be resolved from the request, code, or sensible defaults; never ask whether to proceed or confirm a plan.",
|
||||
"Prefer one question. Put the recommended option first and suffix its label with ` (Recommended)`.",
|
||||
"Do not include an Other option; free text is added automatically.",
|
||||
"If the result is no_answer, continue with best judgment.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -300,6 +300,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
||||
title: "Update Plan",
|
||||
detailKeys: ["explanation", "plan.0.step"],
|
||||
},
|
||||
ask_user: {
|
||||
emoji: "❓",
|
||||
title: "Ask User",
|
||||
detailKeys: ["questions.0.question"],
|
||||
},
|
||||
spawn_task: {
|
||||
emoji: "✨",
|
||||
title: "Suggest Task",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import "./ask-user-tool.js";
|
||||
|
||||
type AskUserToolTestApi = {
|
||||
resetPendingAskUserQuestionsForTest(): void;
|
||||
};
|
||||
|
||||
function getTestApi(): AskUserToolTestApi {
|
||||
const api = (globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.askUserToolTestApi")
|
||||
];
|
||||
if (!api) {
|
||||
throw new Error("ask_user tool test API is unavailable");
|
||||
}
|
||||
return api as AskUserToolTestApi;
|
||||
}
|
||||
|
||||
export function resetPendingAskUserQuestionsForTest(): void {
|
||||
getTestApi().resetPendingAskUserQuestionsForTest();
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js";
|
||||
import { steerActiveSessionWithOptionalDeliveryWait } from "../embedded-agent-runner/run/attempt.queue-message.js";
|
||||
import {
|
||||
createAskUserTool,
|
||||
isAskUserPromptActive,
|
||||
normalizeAskUserParams,
|
||||
reserveAskUserPromptDelivery,
|
||||
settleAskUserPromptDelivery,
|
||||
} from "./ask-user-tool.js";
|
||||
import { resetPendingAskUserQuestionsForTest } from "./ask-user-tool.test-support.js";
|
||||
|
||||
type GatewayCall = NonNullable<Parameters<typeof createAskUserTool>[0]["gatewayCall"]>;
|
||||
|
||||
const validArgs = {
|
||||
questions: [
|
||||
{
|
||||
id: "deploy_target",
|
||||
header: "Deployment target",
|
||||
question: "Where should this deploy?",
|
||||
options: [
|
||||
{ label: "Staging (Recommended)", description: "Safer default" },
|
||||
{ label: "Production" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function gatewayStub(
|
||||
implementation: (
|
||||
method: string,
|
||||
opts: Record<string, unknown>,
|
||||
params: Record<string, unknown>,
|
||||
extra?: { signal?: AbortSignal },
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const mock = vi.fn(implementation);
|
||||
return { mock, call: mock as unknown as GatewayCall };
|
||||
}
|
||||
|
||||
function requestedQuestionId(mock: ReturnType<typeof gatewayStub>["mock"]): string {
|
||||
const requestCall = mock.mock.calls.find(([method]) => method === "question.request");
|
||||
const questionId = requestCall?.[2].id;
|
||||
if (typeof questionId !== "string") {
|
||||
throw new Error("question.request did not include an id");
|
||||
}
|
||||
return questionId;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPendingAskUserQuestionsForTest();
|
||||
});
|
||||
|
||||
describe("ask_user normalization", () => {
|
||||
it("normalizes headers, forces free text, and clamps timeout", () => {
|
||||
const normalized = normalizeAskUserParams({ ...validArgs, timeoutSeconds: 5 });
|
||||
|
||||
expect(normalized.timeoutSeconds).toBe(30);
|
||||
expect(normalized.questions[0]).toMatchObject({
|
||||
id: "deploy_target",
|
||||
header: "Deployment t",
|
||||
isOther: true,
|
||||
});
|
||||
expect(normalizeAskUserParams({ ...validArgs, timeoutSeconds: 9_999 }).timeoutSeconds).toBe(
|
||||
3_600,
|
||||
);
|
||||
expect(Value.Check(createAskUserTool({}).parameters, validArgs)).toBe(true);
|
||||
expect(
|
||||
Value.Check(createAskUserTool({}).parameters, {
|
||||
questions: [{ ...validArgs.questions[0], isSecret: true }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(normalized.questions[0]).not.toHaveProperty("isSecret");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty questions", { questions: [] }, "1 to 3 questions"],
|
||||
[
|
||||
"too many questions",
|
||||
{ questions: Array.from({ length: 4 }, () => validArgs.questions[0]) },
|
||||
"1 to 3 questions",
|
||||
],
|
||||
[
|
||||
"too few options",
|
||||
{ questions: [{ ...validArgs.questions[0], options: [{ label: "Only" }] }] },
|
||||
"2 to 4 options",
|
||||
],
|
||||
[
|
||||
"duplicate ids",
|
||||
{ questions: [validArgs.questions[0], validArgs.questions[0]] },
|
||||
"duplicate question id 'deploy_target'",
|
||||
],
|
||||
[
|
||||
"invalid id",
|
||||
{ questions: [{ ...validArgs.questions[0], id: "Deploy Target" }] },
|
||||
"must be snake_case",
|
||||
],
|
||||
])("rejects %s", (_name, args, error) => {
|
||||
expect(() => normalizeAskUserParams(args)).toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask_user execution", () => {
|
||||
it("returns answered details plus readable answer lines", async () => {
|
||||
const answers = { answers: { deploy_target: { answers: ["Staging (Recommended)"] } } };
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id, expiresAtMs: Date.now() + 30_000 };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return { status: "answered", answers };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const tool = createAskUserTool({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
gatewayCall: gateway.call,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-answered", validArgs);
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
|
||||
expect(result.details).toEqual({ status: "answered", answers });
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Deployment t: Staging (Recommended)"),
|
||||
}),
|
||||
]);
|
||||
expect(gateway.mock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"question.request",
|
||||
{},
|
||||
expect.objectContaining({
|
||||
id: questionId,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
timeoutMs: 900_000,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(gateway.mock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"question.waitAnswer",
|
||||
{ timeoutMs: 910_000 },
|
||||
{ id: questionId, timeoutMs: 900_000 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expired", "No answer arrived"],
|
||||
["pending", "No answer arrived"],
|
||||
["cancelled", "question was cancelled"],
|
||||
] as const)("maps %s to no_answer", async (status, text) => {
|
||||
const gateway = gatewayStub(async (method, _opts, params) =>
|
||||
method === "question.request" ? { id: params.id } : { status },
|
||||
);
|
||||
const result = await createAskUserTool({
|
||||
sessionKey: `agent:main:${status}`,
|
||||
gatewayCall: gateway.call,
|
||||
}).execute(`call-${status}`, validArgs);
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
|
||||
expect(result.details).toEqual({ status: "no_answer" });
|
||||
expect(result.content[0]).toMatchObject({ text: expect.stringContaining(text) });
|
||||
if (status === "pending") {
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: questionId, cancel: true, resolvedBy: "wait-timeout" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a second pending question in the same session", async () => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
finishWait?.({ status: "cancelled" });
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const tool = createAskUserTool({
|
||||
sessionKey: "agent:main:serialized",
|
||||
gatewayCall: gateway.call,
|
||||
});
|
||||
const first = tool.execute("call-first", validArgs);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
|
||||
await expect(tool.execute("call-second", validArgs)).rejects.toThrow(
|
||||
"already has a pending question",
|
||||
);
|
||||
finishWait?.({ status: "cancelled" });
|
||||
await expect(first).resolves.toMatchObject({ details: { status: "no_answer" } });
|
||||
});
|
||||
|
||||
it("cancels the gateway question when the run aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const gateway = gatewayStub(async (method, _opts, params, extra) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
return await new Promise((_resolve, reject) => {
|
||||
extra?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const pending = createAskUserTool({
|
||||
sessionKey: "agent:main:abort",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute("call-abort", validArgs, controller.signal);
|
||||
await vi.waitFor(() =>
|
||||
expect(gateway.mock.mock.calls.some((call) => call[0] === "question.waitAnswer")).toBe(true),
|
||||
);
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
|
||||
controller.abort(new Error("stop"));
|
||||
|
||||
await expect(pending).rejects.toThrow("aborted");
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: questionId, cancel: true, resolvedBy: "run-abort" },
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts registration and still attempts gateway cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const gateway = gatewayStub(async (method, _opts, _params, extra) => {
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
return await new Promise((_resolve, reject) => {
|
||||
extra?.signal?.addEventListener("abort", () => reject(new Error("registration aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const pending = createAskUserTool({
|
||||
sessionKey: "agent:main:register-abort",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute("call-register-abort", validArgs, controller.signal);
|
||||
await vi.waitFor(() =>
|
||||
expect(gateway.mock.mock.calls.some((call) => call[0] === "question.request")).toBe(true),
|
||||
);
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
|
||||
controller.abort(new Error("stop"));
|
||||
|
||||
await expect(pending).rejects.toThrow("registration aborted");
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: questionId, cancel: true, resolvedBy: "run-abort" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not activate prompt delivery when registration ignores an earlier abort", async () => {
|
||||
const sessionKey = "agent:main:late-registration-abort";
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId: "call-late-registration-abort",
|
||||
sessionKey,
|
||||
questions: normalizeAskUserParams(validArgs).questions,
|
||||
});
|
||||
if (!reservation) {
|
||||
throw new Error("expected prompt reservation");
|
||||
}
|
||||
let finishRegistration: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method) => {
|
||||
if (method === "question.request") {
|
||||
return await new Promise((resolve) => {
|
||||
finishRegistration = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-late-registration-abort",
|
||||
validArgs,
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(finishRegistration).toBeTypeOf("function"));
|
||||
|
||||
controller.abort(new Error("stop before registration completed"));
|
||||
finishRegistration?.({ id: reservation.questionId });
|
||||
|
||||
await expect(pending).rejects.toThrow("stop before registration completed");
|
||||
expect(isAskUserPromptActive(reservation.questionId)).toBe(false);
|
||||
});
|
||||
|
||||
it("best-effort cancels a deterministic id after an ambiguous registration failure", async () => {
|
||||
const sessionKey = "agent:main:registration-loss";
|
||||
const gateway = gatewayStub(async (method) => {
|
||||
if (method === "question.request") {
|
||||
throw new Error("connection lost after send");
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-registration-loss",
|
||||
validArgs,
|
||||
),
|
||||
).rejects.toThrow("connection lost after send");
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: questionId, cancel: true, resolvedBy: "registration-failed" },
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels instead of waiting when originating prompt delivery fails", async () => {
|
||||
const sessionKey = "agent:main:delivery-failure";
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId: "call-delivery-failure",
|
||||
sessionKey,
|
||||
questions: normalizeAskUserParams(validArgs).questions,
|
||||
});
|
||||
if (!reservation) {
|
||||
throw new Error("expected prompt reservation");
|
||||
}
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
finishWait?.({ status: "cancelled" });
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-delivery-failure",
|
||||
validArgs,
|
||||
);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
|
||||
settleAskUserPromptDelivery(reservation.questionId, new Error("channel unavailable"));
|
||||
|
||||
await expect(pending).rejects.toThrow("ask_user prompt delivery failed");
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: reservation.questionId, cancel: true, resolvedBy: "prompt-delivery-failed" },
|
||||
);
|
||||
expect(gateway.mock.mock.calls.some((call) => call[0] === "question.waitAnswer")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an answer that wins the prompt-failure cancellation race", async () => {
|
||||
const sessionKey = "agent:main:delivery-answer-race";
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId: "call-delivery-answer-race",
|
||||
sessionKey,
|
||||
questions: normalizeAskUserParams(validArgs).questions,
|
||||
});
|
||||
if (!reservation) {
|
||||
throw new Error("expected prompt reservation");
|
||||
}
|
||||
const answers = { answers: { deploy_target: { answers: ["Production"] } } };
|
||||
let waitCalls = 0;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
waitCalls += 1;
|
||||
if (waitCalls === 1) {
|
||||
return await new Promise<unknown>(() => {});
|
||||
}
|
||||
return { status: "answered", answers };
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
throw Object.assign(new Error("already answered"), {
|
||||
name: "GatewayClientRequestError",
|
||||
details: { reason: "QUESTION_ALREADY_TERMINAL" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-delivery-answer-race",
|
||||
validArgs,
|
||||
);
|
||||
await vi.waitFor(() => expect(waitCalls).toBe(1));
|
||||
|
||||
settleAskUserPromptDelivery(reservation.questionId, new Error("channel unavailable"));
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ details: { status: "answered", answers } });
|
||||
expect(waitCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("aborts while prompt delivery is still pending", async () => {
|
||||
const sessionKey = "agent:main:delivery-abort";
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId: "call-delivery-abort",
|
||||
sessionKey,
|
||||
questions: normalizeAskUserParams(validArgs).questions,
|
||||
});
|
||||
if (!reservation) {
|
||||
throw new Error("expected prompt reservation");
|
||||
}
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params, extra) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve, reject) => {
|
||||
finishWait = resolve;
|
||||
extra?.signal?.addEventListener("abort", () => reject(new Error("wait aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
finishWait?.({ status: "cancelled" });
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-delivery-abort",
|
||||
validArgs,
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(gateway.mock.mock.calls.some((call) => call[0] === "question.request")).toBe(true),
|
||||
);
|
||||
|
||||
controller.abort(new Error("stop during delivery"));
|
||||
|
||||
expect(isAskUserPromptActive(reservation.questionId)).toBe(false);
|
||||
await expect(pending).rejects.toThrow("stop during delivery");
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: reservation.questionId, cancel: true, resolvedBy: "run-abort" },
|
||||
);
|
||||
});
|
||||
|
||||
it("claims unmatched plain text as free text without steering it into the run", async () => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
const answers = params.answers;
|
||||
finishWait?.({ status: "answered", answers });
|
||||
return { status: "answered", answers };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const pending = createAskUserTool({
|
||||
sessionKey: "agent:main:claim",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute("call-claim", validArgs);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const activeSession = { steer, subscribe: vi.fn(() => () => undefined) };
|
||||
const persistApproved = vi.fn(async () => undefined);
|
||||
const recorder = { persistApproved } as unknown as UserTurnTranscriptRecorder;
|
||||
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
activeSession,
|
||||
"A custom destination",
|
||||
{
|
||||
isInboundUserMessage: true,
|
||||
waitForTranscriptCommit: true,
|
||||
userTurnTranscriptRecorder: recorder,
|
||||
},
|
||||
"agent:main:claim",
|
||||
);
|
||||
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
expect(persistApproved).toHaveBeenCalledOnce();
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{},
|
||||
{
|
||||
id: questionId,
|
||||
answers: { answers: { deploy_target: { answers: ["A custom destination"] } } },
|
||||
resolvedBy: "plain-text",
|
||||
},
|
||||
);
|
||||
await expect(pending).resolves.toMatchObject({ details: { status: "answered" } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancellation succeeds", false],
|
||||
["cancellation fails", true],
|
||||
])("keeps image replies on normal steering when %s", async (_name, cancelFails) => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
if (cancelFails) {
|
||||
throw new Error("gateway unavailable");
|
||||
}
|
||||
finishWait?.({ status: "cancelled" });
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const suffix = cancelFails ? "image-cancel-failure" : "image-reply";
|
||||
const sessionKey = `agent:main:${suffix}`;
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
`call-${suffix}`,
|
||||
validArgs,
|
||||
);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
const questionId = requestedQuestionId(gateway.mock);
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const images = [{ type: "image" as const, data: "pixels", mimeType: "image/png" }];
|
||||
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
{ steer, subscribe: vi.fn(() => () => undefined) },
|
||||
"Use this image",
|
||||
{ isInboundUserMessage: true, images },
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
expect(steer).toHaveBeenCalledWith("Use this image", images);
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{
|
||||
id: questionId,
|
||||
cancel: true,
|
||||
resolvedBy: "image-reply",
|
||||
},
|
||||
);
|
||||
if (cancelFails) {
|
||||
finishWait?.({ status: "cancelled" });
|
||||
}
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("confirms a committed plain-text answer after its resolve response is lost", async () => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
let committedAnswers: unknown;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
if (committedAnswers) {
|
||||
return { status: "answered", answers: committedAnswers };
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
committedAnswers = params.answers;
|
||||
finishWait?.({ status: "answered", answers: committedAnswers });
|
||||
throw new Error("response lost after commit");
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const sessionKey = "agent:main:resolve-loss";
|
||||
const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-resolve-loss",
|
||||
validArgs,
|
||||
);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const persistApproved = vi.fn(async () => undefined);
|
||||
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
{ steer, subscribe: vi.fn(() => () => undefined) },
|
||||
"1",
|
||||
{
|
||||
isInboundUserMessage: true,
|
||||
userTurnTranscriptRecorder: { persistApproved } as unknown as UserTurnTranscriptRecorder,
|
||||
},
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
expect(persistApproved).toHaveBeenCalledOnce();
|
||||
await expect(pending).resolves.toMatchObject({ details: { status: "answered" } });
|
||||
});
|
||||
|
||||
it("falls back to normal steering when the gateway question is already terminal", async () => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _opts, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
throw Object.assign(new Error("already answered"), {
|
||||
name: "GatewayClientRequestError",
|
||||
details: { reason: "QUESTION_ALREADY_TERMINAL" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const pending = createAskUserTool({
|
||||
sessionKey: "agent:main:terminal-race",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute("call-terminal-race", validArgs);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
const steer = vi.fn(async () => undefined);
|
||||
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
{ steer, subscribe: vi.fn(() => () => undefined) },
|
||||
"Follow-up message",
|
||||
{ isInboundUserMessage: true },
|
||||
"agent:main:terminal-race",
|
||||
);
|
||||
|
||||
expect(steer).toHaveBeenCalledWith("Follow-up message", undefined);
|
||||
finishWait?.({ status: "cancelled" });
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
/** Built-in blocking user-question tool and its active-session answer bridge. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { Type } from "typebox";
|
||||
import type {
|
||||
QuestionAnswers,
|
||||
QuestionRequestQuestion,
|
||||
QuestionWaitAnswerResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
} from "../harness/user-input-bridge.js";
|
||||
import { ASK_USER_TOOL_DISPLAY_SUMMARY, describeAskUserTool } from "../tool-description-presets.js";
|
||||
import { type AnyAgentTool, ToolInputError, textResult } from "./common.js";
|
||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||
|
||||
const DEFAULT_ASK_USER_TIMEOUT_SECONDS = 900;
|
||||
const MIN_ASK_USER_TIMEOUT_SECONDS = 30;
|
||||
const MAX_ASK_USER_TIMEOUT_SECONDS = 3600;
|
||||
const ASK_USER_RPC_GRACE_MS = 10_000;
|
||||
const QUESTION_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
const TERMINAL_QUESTION_ERROR_REASONS = new Set([
|
||||
"QUESTION_ALREADY_TERMINAL",
|
||||
"QUESTION_NOT_FOUND",
|
||||
]);
|
||||
|
||||
const AskUserToolSchema = Type.Object(
|
||||
{
|
||||
questions: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
id: Type.String({
|
||||
minLength: 1,
|
||||
pattern: "^[a-z][a-z0-9_]*$",
|
||||
description: "Unique snake_case answer key.",
|
||||
}),
|
||||
header: Type.String({
|
||||
minLength: 1,
|
||||
description: "Short chip label; longer input is truncated to 12 characters.",
|
||||
}),
|
||||
question: Type.String({
|
||||
minLength: 1,
|
||||
description: "Single-sentence question for the user.",
|
||||
}),
|
||||
options: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
label: Type.String({ minLength: 1 }),
|
||||
description: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
{ minItems: 2, maxItems: 4 },
|
||||
),
|
||||
multiSelect: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
{ minItems: 1, maxItems: 3 },
|
||||
),
|
||||
timeoutSeconds: Type.Optional(Type.Integer()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type AskUserGatewayCall = (
|
||||
method: string,
|
||||
opts: GatewayCallOptions,
|
||||
params?: unknown,
|
||||
extra?: { signal?: AbortSignal },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type AskUserQuestionPhase =
|
||||
| { kind: "reserved" }
|
||||
| { kind: "registering" }
|
||||
| { kind: "prompting" }
|
||||
| { kind: "answerable" }
|
||||
| { kind: "resolving" }
|
||||
| { kind: "prompt-failed"; error: unknown };
|
||||
|
||||
type AskUserQuestionState = {
|
||||
questionId: string;
|
||||
sessionKey: string;
|
||||
questions: QuestionRequestQuestion[];
|
||||
phase: AskUserQuestionPhase;
|
||||
gatewayCall?: AskUserGatewayCall;
|
||||
answer?: Promise<QuestionWaitAnswerResult>;
|
||||
waiters: Set<() => void>;
|
||||
};
|
||||
|
||||
const askUserQuestions = new Map<string, AskUserQuestionState>();
|
||||
|
||||
type NormalizedAskUserParams = {
|
||||
questions: QuestionRequestQuestion[];
|
||||
timeoutSeconds: number;
|
||||
};
|
||||
|
||||
function readRequiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ToolInputError(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeOption(value: unknown, questionIndex: number, optionIndex: number) {
|
||||
const labelPrefix = `questions[${questionIndex}].options[${optionIndex}]`;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ToolInputError(`${labelPrefix} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const label = readRequiredString(record.label, `${labelPrefix}.label`);
|
||||
// Telegram button text caps at 64 chars — the tightest native transport.
|
||||
// Bounding here keeps schema-valid prompts deliverable on every channel.
|
||||
if (label.length > 64) {
|
||||
throw new ToolInputError(`${labelPrefix}.label must be at most 64 characters (use 1-5 words)`);
|
||||
}
|
||||
if (record.description !== undefined && typeof record.description !== "string") {
|
||||
throw new ToolInputError(`${labelPrefix}.description must be a string`);
|
||||
}
|
||||
const description =
|
||||
typeof record.description === "string" ? record.description.trim() : undefined;
|
||||
return { label, ...(description ? { description } : {}) };
|
||||
}
|
||||
|
||||
/** Validates and canonicalizes model-authored ask_user arguments. */
|
||||
export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ToolInputError("ask_user arguments must be an object");
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
if (
|
||||
!Array.isArray(params.questions) ||
|
||||
params.questions.length < 1 ||
|
||||
params.questions.length > 3
|
||||
) {
|
||||
throw new ToolInputError("questions must contain 1 to 3 questions");
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const questions = params.questions.map(
|
||||
(questionValue, questionIndex): QuestionRequestQuestion => {
|
||||
const prefix = `questions[${questionIndex}]`;
|
||||
if (!questionValue || typeof questionValue !== "object" || Array.isArray(questionValue)) {
|
||||
throw new ToolInputError(`${prefix} must be an object`);
|
||||
}
|
||||
const question = questionValue as Record<string, unknown>;
|
||||
const id = readRequiredString(question.id, `${prefix}.id`);
|
||||
if (!QUESTION_ID_PATTERN.test(id)) {
|
||||
throw new ToolInputError(`${prefix}.id must be snake_case (for example, deploy_target)`);
|
||||
}
|
||||
if (ids.has(id)) {
|
||||
throw new ToolInputError(`duplicate question id '${id}'`);
|
||||
}
|
||||
ids.add(id);
|
||||
const header = truncateUtf16Safe(readRequiredString(question.header, `${prefix}.header`), 12);
|
||||
const questionText = readRequiredString(question.question, `${prefix}.question`);
|
||||
if (
|
||||
!Array.isArray(question.options) ||
|
||||
question.options.length < 2 ||
|
||||
question.options.length > 4
|
||||
) {
|
||||
throw new ToolInputError(`${prefix}.options must contain 2 to 4 options`);
|
||||
}
|
||||
if (question.multiSelect !== undefined && typeof question.multiSelect !== "boolean") {
|
||||
throw new ToolInputError(`${prefix}.multiSelect must be a boolean`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
question: questionText,
|
||||
options: question.options.map((option, optionIndex) =>
|
||||
normalizeOption(option, questionIndex, optionIndex),
|
||||
),
|
||||
...(question.multiSelect === true ? { multiSelect: true } : {}),
|
||||
isOther: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const rawTimeoutSeconds = params.timeoutSeconds;
|
||||
if (
|
||||
rawTimeoutSeconds !== undefined &&
|
||||
(typeof rawTimeoutSeconds !== "number" ||
|
||||
!Number.isFinite(rawTimeoutSeconds) ||
|
||||
!Number.isInteger(rawTimeoutSeconds))
|
||||
) {
|
||||
throw new ToolInputError("timeoutSeconds must be an integer");
|
||||
}
|
||||
const timeoutSeconds = Math.min(
|
||||
MAX_ASK_USER_TIMEOUT_SECONDS,
|
||||
Math.max(MIN_ASK_USER_TIMEOUT_SECONDS, rawTimeoutSeconds ?? DEFAULT_ASK_USER_TIMEOUT_SECONDS),
|
||||
);
|
||||
return { questions, timeoutSeconds };
|
||||
}
|
||||
|
||||
/** Stable client-generated gateway question id shared with tool-start delivery. */
|
||||
function buildAskUserQuestionId(toolCallId: string, sessionKey?: string): string {
|
||||
const identity = `${sessionKey?.trim() ?? ""}\0${toolCallId}`;
|
||||
return `ask_${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
function askUserSessionKey(sessionKey: string | undefined, agentId?: string): string {
|
||||
return sessionKey?.trim() || (agentId?.trim() ? `agent:${agentId.trim()}` : "session:unknown");
|
||||
}
|
||||
|
||||
function findAskUserQuestionForSession(sessionKey: string): AskUserQuestionState | undefined {
|
||||
for (const question of askUserQuestions.values()) {
|
||||
if (question.sessionKey === sessionKey) {
|
||||
return question;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transitionAskUserQuestion(state: AskUserQuestionState, phase: AskUserQuestionPhase): void {
|
||||
state.phase = phase;
|
||||
for (const wake of state.waiters) {
|
||||
wake();
|
||||
}
|
||||
state.waiters.clear();
|
||||
}
|
||||
|
||||
function releaseAskUserQuestion(questionId: string): void {
|
||||
const state = askUserQuestions.get(questionId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
askUserQuestions.delete(questionId);
|
||||
for (const wake of state.waiters) {
|
||||
wake();
|
||||
}
|
||||
state.waiters.clear();
|
||||
}
|
||||
|
||||
async function waitForQuestionChange(
|
||||
state: AskUserQuestionState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const wake = () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
state.waiters.delete(wake);
|
||||
reject(signal?.reason instanceof Error ? signal.reason : new Error("ask_user aborted"));
|
||||
};
|
||||
state.waiters.add(wake);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserves one visible ask_user prompt slot before subscriber delivery. */
|
||||
export function reserveAskUserPromptDelivery(params: {
|
||||
toolCallId: string;
|
||||
sessionKey?: string;
|
||||
questions: QuestionRequestQuestion[];
|
||||
}): { questionId: string } | undefined {
|
||||
const sessionKey = askUserSessionKey(params.sessionKey);
|
||||
if (findAskUserQuestionForSession(sessionKey)) {
|
||||
return undefined;
|
||||
}
|
||||
const questionId = buildAskUserQuestionId(params.toolCallId, params.sessionKey);
|
||||
if (askUserQuestions.has(questionId)) {
|
||||
return undefined;
|
||||
}
|
||||
askUserQuestions.set(questionId, {
|
||||
questionId,
|
||||
sessionKey,
|
||||
questions: params.questions,
|
||||
phase: { kind: "reserved" },
|
||||
waiters: new Set(),
|
||||
});
|
||||
return { questionId };
|
||||
}
|
||||
|
||||
/** Waits until policy-accepted tool execution has registered the gateway question. */
|
||||
export async function waitForAskUserPromptReady(
|
||||
questionId: string,
|
||||
): Promise<QuestionRequestQuestion[] | undefined> {
|
||||
const state = askUserQuestions.get(questionId);
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
while (askUserQuestions.get(questionId) === state) {
|
||||
if (
|
||||
state.phase.kind === "prompting" ||
|
||||
state.phase.kind === "answerable" ||
|
||||
state.phase.kind === "resolving" ||
|
||||
state.phase.kind === "prompt-failed"
|
||||
) {
|
||||
return state.questions;
|
||||
}
|
||||
await waitForQuestionChange(state);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Opens prompt delivery after question.request succeeds. */
|
||||
function markAskUserPromptReady(questionId: string, questions: QuestionRequestQuestion[]): void {
|
||||
const state = askUserQuestions.get(questionId);
|
||||
if (!state || (state.phase.kind !== "reserved" && state.phase.kind !== "registering")) {
|
||||
return;
|
||||
}
|
||||
state.questions = questions;
|
||||
transitionAskUserQuestion(state, { kind: "prompting" });
|
||||
}
|
||||
|
||||
/** Records whether the originating-conversation prompt reached its delivery callback. */
|
||||
export function settleAskUserPromptDelivery(questionId: string, error?: unknown): void {
|
||||
const state = askUserQuestions.get(questionId);
|
||||
if (!state || state.phase.kind !== "prompting") {
|
||||
return;
|
||||
}
|
||||
transitionAskUserQuestion(
|
||||
state,
|
||||
error === undefined ? { kind: "answerable" } : { kind: "prompt-failed", error },
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether a question-associated prompt still belongs to a blocking ask_user call. */
|
||||
export function isAskUserPromptActive(questionId: string): boolean {
|
||||
return askUserQuestions.has(questionId);
|
||||
}
|
||||
|
||||
/** Releases a tool-start reservation when policy rejects execution. */
|
||||
export function cancelAskUserPromptDelivery(toolCallId: string, sessionKey?: string): void {
|
||||
releaseAskUserQuestion(buildAskUserQuestionId(toolCallId, sessionKey));
|
||||
}
|
||||
|
||||
function answeredResult(questions: readonly QuestionRequestQuestion[], answers: QuestionAnswers) {
|
||||
const payload = { status: "answered" as const, answers };
|
||||
const lines = questions.map((question) => {
|
||||
const values = answers.answers[question.id]?.answers ?? [];
|
||||
return `${question.header}: ${values.length > 0 ? values.join(", ") : "(no answer)"}`;
|
||||
});
|
||||
return textResult(`${lines.join("\n")}\n\n${JSON.stringify(payload, null, 2)}`, payload);
|
||||
}
|
||||
|
||||
function noAnswerResult(status: Exclude<QuestionWaitAnswerResult["status"], "answered">) {
|
||||
const payload = { status: "no_answer" as const };
|
||||
const note =
|
||||
status === "cancelled"
|
||||
? "The question was cancelled; proceed with best judgment."
|
||||
: "No answer arrived; proceed with best judgment.";
|
||||
return textResult(`${note}\n\n${JSON.stringify(payload, null, 2)}`, payload);
|
||||
}
|
||||
|
||||
async function waitForPromptDelivery(
|
||||
state: AskUserQuestionState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ error?: unknown }> {
|
||||
while (askUserQuestions.get(state.questionId) === state) {
|
||||
if (state.phase.kind === "answerable" || state.phase.kind === "resolving") {
|
||||
return {};
|
||||
}
|
||||
if (state.phase.kind === "prompt-failed") {
|
||||
return { error: state.phase.error };
|
||||
}
|
||||
await waitForQuestionChange(state, signal);
|
||||
}
|
||||
return { error: new Error("ask_user prompt is no longer active") };
|
||||
}
|
||||
|
||||
function readQuestionErrorReason(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const requestError = error as { details?: unknown; name?: unknown };
|
||||
if (requestError.name !== "GatewayClientRequestError") {
|
||||
return undefined;
|
||||
}
|
||||
const details = requestError.details;
|
||||
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
||||
return undefined;
|
||||
}
|
||||
const reason = (details as { reason?: unknown }).reason;
|
||||
return typeof reason === "string" ? reason : undefined;
|
||||
}
|
||||
|
||||
function isTerminalQuestionResolveError(error: unknown): boolean {
|
||||
const reason = readQuestionErrorReason(error);
|
||||
return reason !== undefined && TERMINAL_QUESTION_ERROR_REASONS.has(reason);
|
||||
}
|
||||
|
||||
async function observeCommittedAnswer(
|
||||
answer: Promise<QuestionWaitAnswerResult> | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!answer) {
|
||||
return false;
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
answer,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), 1_000);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
return result?.status === "answered";
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Claims the next queued plain-text message for this session's active question. */
|
||||
export async function claimPendingAskUserAnswer(params: {
|
||||
sessionKey?: string;
|
||||
text: string;
|
||||
persist?: () => Promise<void>;
|
||||
}): Promise<boolean> {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const state = findAskUserQuestionForSession(sessionKey);
|
||||
if (!state || state.phase.kind !== "answerable" || !state.gatewayCall) {
|
||||
return false;
|
||||
}
|
||||
transitionAskUserQuestion(state, { kind: "resolving" });
|
||||
try {
|
||||
await params.persist?.();
|
||||
} catch (error) {
|
||||
transitionAskUserQuestion(state, { kind: "answerable" });
|
||||
throw error;
|
||||
}
|
||||
const answers = buildAgentHarnessUserInputAnswers(
|
||||
state.questions as AgentHarnessUserInputQuestion[],
|
||||
params.text,
|
||||
);
|
||||
try {
|
||||
await state.gatewayCall(
|
||||
"question.resolve",
|
||||
{},
|
||||
{ id: state.questionId, answers, resolvedBy: "plain-text" },
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isTerminalQuestionResolveError(error)) {
|
||||
return false;
|
||||
}
|
||||
// The long-lived wait observes a resolve that committed even when its response was lost.
|
||||
// Reusing it avoids a second gateway read and answer-shape comparison.
|
||||
if (await observeCommittedAnswer(state.answer)) {
|
||||
return true;
|
||||
}
|
||||
transitionAskUserQuestion(state, { kind: "answerable" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels the blocking question before an inbound turn takes another route. */
|
||||
export async function cancelPendingAskUserForSession(params: {
|
||||
sessionKey?: string;
|
||||
resolvedBy: string;
|
||||
}): Promise<boolean> {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const state = findAskUserQuestionForSession(sessionKey);
|
||||
if (!state || state.phase.kind === "reserved" || !state.gatewayCall) {
|
||||
return false;
|
||||
}
|
||||
while (state.phase.kind === "registering") {
|
||||
await waitForQuestionChange(state);
|
||||
if (askUserQuestions.get(state.questionId) !== state) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (state.phase.kind === "resolving" || state.phase.kind === "prompt-failed") {
|
||||
return false;
|
||||
}
|
||||
const previousPhase = state.phase;
|
||||
transitionAskUserQuestion(state, { kind: "resolving" });
|
||||
try {
|
||||
await state.gatewayCall(
|
||||
"question.resolve",
|
||||
{ timeoutMs: ASK_USER_RPC_GRACE_MS },
|
||||
{ id: state.questionId, cancel: true, resolvedBy: params.resolvedBy },
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isTerminalQuestionResolveError(error)) {
|
||||
return true;
|
||||
}
|
||||
transitionAskUserQuestion(state, previousPhase);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPendingAskUserQuestionsForTest(): void {
|
||||
for (const questionId of askUserQuestions.keys()) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.askUserToolTestApi")] = {
|
||||
resetPendingAskUserQuestionsForTest,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates the main-session-only blocking ask_user tool. */
|
||||
export function createAskUserTool(params: {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
gatewayCall?: AskUserGatewayCall;
|
||||
}): AnyAgentTool {
|
||||
const gatewayCall: AskUserGatewayCall = params.gatewayCall ?? callGatewayTool;
|
||||
return {
|
||||
label: "Ask User",
|
||||
name: "ask_user",
|
||||
displaySummary: ASK_USER_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeAskUserTool(),
|
||||
parameters: AskUserToolSchema,
|
||||
execute: async (toolCallId, args, signal) => {
|
||||
const questionId = buildAskUserQuestionId(toolCallId, params.sessionKey);
|
||||
let normalized: NormalizedAskUserParams;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
normalized = normalizeAskUserParams(args);
|
||||
} catch (error) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
throw error;
|
||||
}
|
||||
const sessionKey = askUserSessionKey(params.sessionKey, params.agentId);
|
||||
const reserved = askUserQuestions.get(questionId);
|
||||
const existing = findAskUserQuestionForSession(sessionKey);
|
||||
if ((reserved && reserved.phase.kind !== "reserved") || (existing && existing !== reserved)) {
|
||||
throw new ToolInputError(
|
||||
"ask_user already has a pending question for this session; wait for it to resolve before asking another",
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = normalized.timeoutSeconds * 1_000;
|
||||
const deliverPrompt = reserved?.phase.kind === "reserved";
|
||||
const state: AskUserQuestionState =
|
||||
reserved ??
|
||||
({
|
||||
questionId,
|
||||
sessionKey,
|
||||
questions: normalized.questions,
|
||||
phase: { kind: "registering" },
|
||||
gatewayCall,
|
||||
waiters: new Set(),
|
||||
} satisfies AskUserQuestionState);
|
||||
state.sessionKey = sessionKey;
|
||||
state.questions = normalized.questions;
|
||||
state.gatewayCall = gatewayCall;
|
||||
transitionAskUserQuestion(state, { kind: "registering" });
|
||||
askUserQuestions.set(questionId, state);
|
||||
|
||||
let cancellation:
|
||||
| Promise<Extract<QuestionWaitAnswerResult, { status: "answered" }> | undefined>
|
||||
| undefined;
|
||||
let registered = false;
|
||||
const cancelPendingQuestion = (resolvedBy: string) => {
|
||||
cancellation ??= (async () => {
|
||||
try {
|
||||
await gatewayCall(
|
||||
"question.resolve",
|
||||
{ timeoutMs: ASK_USER_RPC_GRACE_MS },
|
||||
{ id: questionId, cancel: true, resolvedBy },
|
||||
);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (!isTerminalQuestionResolveError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const result = (await gatewayCall(
|
||||
"question.waitAnswer",
|
||||
{ timeoutMs: ASK_USER_RPC_GRACE_MS },
|
||||
{ id: questionId, timeoutMs: 1_000 },
|
||||
)) as QuestionWaitAnswerResult;
|
||||
return result.status === "answered" ? result : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
return cancellation;
|
||||
};
|
||||
const cancelOnAbort = () => {
|
||||
if (askUserQuestions.get(questionId) === state) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
}
|
||||
void cancelPendingQuestion("run-abort");
|
||||
};
|
||||
const finishWait = async (result: QuestionWaitAnswerResult) => {
|
||||
if (result.status === "pending") {
|
||||
const answered = await cancelPendingQuestion("wait-timeout");
|
||||
if (answered) {
|
||||
return answeredResult(normalized.questions, answered.answers);
|
||||
}
|
||||
}
|
||||
if (result.status === "answered") {
|
||||
return answeredResult(normalized.questions, result.answers);
|
||||
}
|
||||
if (
|
||||
result.status === "pending" ||
|
||||
result.status === "expired" ||
|
||||
result.status === "cancelled"
|
||||
) {
|
||||
return noAnswerResult(result.status);
|
||||
}
|
||||
throw new Error("question.waitAnswer returned an invalid status");
|
||||
};
|
||||
|
||||
try {
|
||||
const requestResult = (await gatewayCall(
|
||||
"question.request",
|
||||
{},
|
||||
{
|
||||
id: questionId,
|
||||
questions: normalized.questions,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
timeoutMs,
|
||||
},
|
||||
signal ? { signal } : undefined,
|
||||
)) as { id?: unknown };
|
||||
registered = true;
|
||||
if (requestResult.id !== questionId) {
|
||||
throw new Error("question.request returned an unexpected question id");
|
||||
}
|
||||
signal?.addEventListener("abort", cancelOnAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
cancelOnAbort();
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
const answerPromise = gatewayCall(
|
||||
"question.waitAnswer",
|
||||
{ timeoutMs: timeoutMs + ASK_USER_RPC_GRACE_MS },
|
||||
{ id: questionId, timeoutMs },
|
||||
signal ? { signal } : undefined,
|
||||
) as Promise<QuestionWaitAnswerResult>;
|
||||
state.answer = answerPromise;
|
||||
if (deliverPrompt) {
|
||||
// Tool-start reserves the prompt, but only a committed Gateway record opens delivery.
|
||||
// This prevents channels from exposing a question ID that cannot accept an answer.
|
||||
markAskUserPromptReady(questionId, normalized.questions);
|
||||
const promptDeliveryPromise = waitForPromptDelivery(state, signal);
|
||||
const first = await Promise.race([
|
||||
promptDeliveryPromise.then((result) => ({
|
||||
kind: "delivery" as const,
|
||||
result,
|
||||
})),
|
||||
answerPromise.then((result) => ({ kind: "answer" as const, result })),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
if (first.kind === "answer") {
|
||||
return await finishWait(first.result);
|
||||
}
|
||||
const deliveryResult = first.result;
|
||||
if (deliveryResult.error !== undefined) {
|
||||
const answered = await cancelPendingQuestion("prompt-delivery-failed");
|
||||
if (answered) {
|
||||
return answeredResult(normalized.questions, answered.answers);
|
||||
}
|
||||
throw new Error("ask_user prompt delivery failed", { cause: deliveryResult.error });
|
||||
}
|
||||
} else {
|
||||
transitionAskUserQuestion(state, { kind: "answerable" });
|
||||
}
|
||||
const result = await state.answer;
|
||||
signal?.throwIfAborted();
|
||||
return await finishWait(result);
|
||||
} catch (error) {
|
||||
if (registered || readQuestionErrorReason(error) !== "QUESTION_ID_IN_USE") {
|
||||
const answered = await cancelPendingQuestion(
|
||||
signal?.aborted ? "run-abort" : registered ? "tool-error" : "registration-failed",
|
||||
);
|
||||
if (!signal?.aborted && answered) {
|
||||
return answeredResult(normalized.questions, answered.answers);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", cancelOnAbort);
|
||||
if (askUserQuestions.get(questionId) === state) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -299,6 +299,7 @@ describe("gateway tool defaults", () => {
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.questions",
|
||||
"operator.pairing",
|
||||
"operator.talk.secrets",
|
||||
]);
|
||||
|
||||
@@ -2172,6 +2172,7 @@ describe("message tool schema scoping", () => {
|
||||
]),
|
||||
);
|
||||
expect(presentationSchemaJson).not.toContain('"const":"approval"');
|
||||
expect(presentationSchemaJson).not.toContain('"const":"question"');
|
||||
expect(presentationSchemaJson).toContain('"chartType"');
|
||||
expect(presentationSchemaJson).toContain('"pie"');
|
||||
expect(presentationSchemaJson).toContain('"table"');
|
||||
|
||||
@@ -574,8 +574,8 @@ const presentationCommandOrCallbackActionSchema = Type.Union([
|
||||
presentationCallbackActionSchema,
|
||||
]);
|
||||
|
||||
// Approval actions carry server-issued IDs and are runtime-authored only. The
|
||||
// message tool exposes the remaining button actions that models may safely author.
|
||||
// Approval and question actions carry server-issued IDs and are runtime-authored
|
||||
// only. The message tool exposes the remaining actions models may safely author.
|
||||
const presentationButtonActionSchema = Type.Union([
|
||||
presentationCommandActionSchema,
|
||||
presentationCallbackActionSchema,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type ReplyPayload = {
|
||||
sensitiveMedia?: boolean;
|
||||
/** Channel-agnostic rich presentation. Core degrades or asks the channel renderer to map it. */
|
||||
presentation?: MessagePresentation;
|
||||
/** Runtime-authored text is the exact fallback, not additional native presentation content. */
|
||||
presentationTextMode?: "fallback";
|
||||
/** Channel-agnostic delivery preferences, e.g. pin the sent message when supported. */
|
||||
delivery?: ReplyPayloadDelivery;
|
||||
/**
|
||||
|
||||
@@ -443,6 +443,7 @@ describe("runReplyAgent media path normalization", () => {
|
||||
"generate chart",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
taskSuggestionDeliveryMode: "gateway",
|
||||
},
|
||||
);
|
||||
@@ -478,6 +479,7 @@ describe("runReplyAgent media path normalization", () => {
|
||||
"compare these",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
images,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
@@ -544,7 +546,11 @@ describe("runReplyAgent media path normalization", () => {
|
||||
expect(queueEmbeddedAgentMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith(
|
||||
"session",
|
||||
"summarize the audio",
|
||||
{ steeringMode: "all", taskSuggestionDeliveryMode: undefined },
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
);
|
||||
expect(enqueueFollowupRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1412,6 +1412,7 @@ export async function runReplyAgent(params: {
|
||||
followupRun.prompt,
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
...(followupRun.images?.length ? { images: followupRun.images } : {}),
|
||||
...(turnAdoptionLifecycle ? { waitForTranscriptCommit: true } : {}),
|
||||
...(resolvedQueue.debounceMs !== undefined ? { debounceMs: resolvedQueue.debounceMs } : {}),
|
||||
|
||||
@@ -56,6 +56,7 @@ function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
||||
text: reply.trimmedText,
|
||||
mediaList: reply.mediaUrls,
|
||||
presentation: payload.presentation ?? null,
|
||||
presentationTextMode: payload.presentationTextMode ?? null,
|
||||
interactive: payload.interactive ?? null,
|
||||
channelData: payload.channelData ?? null,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
@@ -72,6 +73,7 @@ export function createBlockReplyContentKey(payload: ReplyPayload): string {
|
||||
text: reply.trimmedText,
|
||||
mediaList: reply.mediaUrls,
|
||||
presentation: payload.presentation ?? null,
|
||||
presentationTextMode: payload.presentationTextMode ?? null,
|
||||
interactive: payload.interactive ?? null,
|
||||
channelData: payload.channelData ?? null,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("handleSteerCommand", () => {
|
||||
"keep going",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
@@ -79,6 +80,7 @@ describe("handleSteerCommand", () => {
|
||||
"keep going",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: "gateway",
|
||||
},
|
||||
@@ -103,6 +105,7 @@ describe("handleSteerCommand", () => {
|
||||
"check the target",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
@@ -126,6 +129,7 @@ describe("handleSteerCommand", () => {
|
||||
"continue from state",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
@@ -165,6 +169,7 @@ describe("handleSteerCommand", () => {
|
||||
"check the active file",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
@@ -194,6 +199,7 @@ describe("handleSteerCommand", () => {
|
||||
"use the active direct lane",
|
||||
{
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
taskSuggestionDeliveryMode: undefined,
|
||||
},
|
||||
|
||||
@@ -172,6 +172,7 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman
|
||||
|
||||
const queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, message, {
|
||||
steeringMode: "all",
|
||||
isInboundUserMessage: true,
|
||||
debounceMs: 0,
|
||||
...(params.opts?.sourceReplyDeliveryMode
|
||||
? { sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode }
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createFinalDispatchPayloadDedupeKey(payload: ReplyPayload): stri
|
||||
trustedLocalMedia: payload.trustedLocalMedia,
|
||||
sensitiveMedia: payload.sensitiveMedia,
|
||||
presentation: payload.presentation,
|
||||
presentationTextMode: payload.presentationTextMode,
|
||||
delivery: payload.delivery,
|
||||
interactive: payload.interactive,
|
||||
btw: payload.btw,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../../agents/subagent-capabilities.js";
|
||||
import { isToolAllowedByPolicies } from "../../agents/tool-policy-match.js";
|
||||
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js";
|
||||
import { isAskUserPromptActive } from "../../agents/tools/ask-user-tool.js";
|
||||
import {
|
||||
resolveConversationBindingRecord,
|
||||
touchConversationBindingRecord,
|
||||
@@ -1395,12 +1396,24 @@ async function dispatchReplyFromConfigInner(
|
||||
: undefined;
|
||||
return execApproval && typeof execApproval === "object" && !Array.isArray(execApproval);
|
||||
};
|
||||
const hasAskUserPayload = (payload: ReplyPayload) => {
|
||||
const askUser = payload.channelData?.askUser;
|
||||
return askUser && typeof askUser === "object" && !Array.isArray(askUser);
|
||||
};
|
||||
const isInactiveAskUserPayload = (payload: ReplyPayload) => {
|
||||
const askUser = payload.channelData?.askUser;
|
||||
if (!askUser || typeof askUser !== "object" || Array.isArray(askUser)) {
|
||||
return false;
|
||||
}
|
||||
const questionId = (askUser as { questionId?: unknown }).questionId;
|
||||
return typeof questionId === "string" && !isAskUserPromptActive(questionId);
|
||||
};
|
||||
const shouldSuppressLateTextOnlyToolProgress = (payload: ReplyPayload) => {
|
||||
if (!finalReplyDeliveryStarted) {
|
||||
return false;
|
||||
}
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
return !reply.hasMedia && !hasExecApprovalPayload(payload);
|
||||
return !reply.hasMedia && !hasExecApprovalPayload(payload) && !hasAskUserPayload(payload);
|
||||
};
|
||||
// Durable inter-tool commentary lane: with verbose progress on, preamble
|
||||
// items become standalone progress messages like tool summaries. The latest
|
||||
@@ -1892,6 +1905,9 @@ async function dispatchReplyFromConfigInner(
|
||||
if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) {
|
||||
return payload;
|
||||
}
|
||||
if (hasAskUserPayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (isFastModeAutoProgressPayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
@@ -2235,12 +2251,18 @@ async function dispatchReplyFromConfigInner(
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (isInactiveAskUserPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
await waitForPendingDirectBlockReplyDelivery(
|
||||
getDispatchAbortOperation()?.abortSignal,
|
||||
);
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (isInactiveAskUserPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
// Buffered commentary preceded this tool; land it before the summary.
|
||||
await flushPendingCommentaryProgress();
|
||||
@@ -2312,6 +2334,9 @@ async function dispatchReplyFromConfigInner(
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (isInactiveAskUserPayload(deliveryPayload)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldSuppressLateTextOnlyToolProgress(deliveryPayload) &&
|
||||
!isFastModeAutoProgressPayload(deliveryPayload) &&
|
||||
|
||||
@@ -30,6 +30,8 @@ type ReplyBackendCancelReason = "user_abort" | "restart" | "superseded";
|
||||
|
||||
export type ReplyBackendQueueMessageOptions = {
|
||||
steeringMode?: "all";
|
||||
/** True when this queue item came from the channel's current user turn. */
|
||||
isInboundUserMessage?: boolean;
|
||||
debounceMs?: number;
|
||||
/** Ordered current-turn images to inject with the steering text. */
|
||||
images?: ImageContent[];
|
||||
|
||||
@@ -757,6 +757,7 @@ describe("callGateway url resolution", () => {
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.questions",
|
||||
"operator.pairing",
|
||||
"operator.talk.secrets",
|
||||
]);
|
||||
@@ -779,6 +780,7 @@ describe("callGateway url resolution", () => {
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.questions",
|
||||
"operator.pairing",
|
||||
"operator.talk.secrets",
|
||||
]);
|
||||
|
||||
@@ -468,6 +468,22 @@ describe("gateway broadcaster", () => {
|
||||
expectSentEvents(adminSocket, expectedEvents);
|
||||
});
|
||||
|
||||
it("requires operator.questions for question broadcasts", () => {
|
||||
const questionSocket: TestSocket = { bufferedAmount: 0, send: vi.fn(), close: vi.fn() };
|
||||
const readSocket: TestSocket = { bufferedAmount: 0, send: vi.fn(), close: vi.fn() };
|
||||
const clients = new Set<GatewayWsClient>([
|
||||
makeOperatorWsClient("c-questions", questionSocket, ["operator.questions"]),
|
||||
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
|
||||
]);
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
|
||||
broadcast("question.requested", { id: "question-1" });
|
||||
broadcast("question.resolved", { id: "question-1", status: "expired" });
|
||||
|
||||
expect(questionSocket.send).toHaveBeenCalledTimes(2);
|
||||
expect(readSocket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires operator.read for task ledger broadcast events", () => {
|
||||
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
|
||||
makeScopedBroadcastContext();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user