/** * Interactive user-choices and permission approvals bridged into the chat * channel (Feishu first). * * DSH exposes both as cordis **waterfall** events on the calling agent's scope: * * `user-questions/request` — request `{ questions, agent?, signal? }`, * result `{ answers: [{ id, selected, custom? }] }` * `approval/request` — request `{ agent, toolName, callId?, reason?, signal? }`, * result `'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'` * * A listener that *returns a value* answers the request; calling `next()` hands * it to the following listener (the host's Web-GUI forwarder, and ultimately * the fail-closed default). So this bridge is a genuine answerer, not a client * of something else: it used to subscribe to the host's `apiProxy` mux stream, * but no such host service exists, and every question silently fell through — * which is why a chat user saw no options at all. * * Two properties of the dispatch shape the registration: * * - Listeners are registered with `prepend: true`. The host's api-remotes * plugin registers its own forwarder on these events and *parks* the * waterfall on it while it waits for a Web client to answer; with no browser * tab connected that promise never settles, so a listener registered after it * would never run at all. Going first is the only placement that always runs. * - The listener is registered on the plugin's own unscoped context, which * `scopeTarget` admits to every scoped dispatch. `request.agent.id` is what * decides whether one of *our* chats owns the session. * * Everything stays best-effort: whenever no binding matches, the prompt cannot * be delivered, or the request is cancelled, the listener calls `next()` and * the host's normal path (Web GUI, then fail-closed) takes over. * @module dsh-connect/interaction */ import type { Context } from "@deepseek-ai/cordis"; import type { ChannelAdapter } from "./types.js"; import type { BindingStore } from "./binding.js"; import type { ResolvedConnectConfig } from "./runner.js"; /** One question from a `user-questions/request` dispatch. */ export interface AskQuestionLike { id: string; question: string; header?: string; detail?: string; options?: { label: string; description?: string; }[]; multiSelect?: boolean; } /** One answer as the host expects it. */ interface AskAnswerItem { id: string; selected: string[]; custom?: string; } /** An answer for one question, before it is tagged with that question's id. */ type AskAnswerValue = Omit; /** Result of a `user-questions/request` waterfall. */ export interface AskAnswer { answers: AskAnswerItem[]; } /** Payload of a `user-questions/request` waterfall. */ export interface AskRequestEvent { questions: AskQuestionLike[]; agent?: { id?: string; }; signal?: AbortSignal; } /** Result of an `approval/request` waterfall. */ export type ApprovalOutcome = "allowed-once" | "rejected" | "cancelled" | "unavailable"; /** Payload of an `approval/request` waterfall. */ export interface ApprovalRequestEvent { readonly agent?: { id?: string; }; readonly toolName: string; readonly callId?: string; readonly reason?: string; readonly signal?: AbortSignal; } /** * Decode a free-text chat reply into the answer for one question. Exact label * match wins; otherwise space/comma separated numbers are mapped onto the * options (multi-select accumulates them); anything else becomes free text. */ export declare function decodeTextAnswer(q: AskQuestionLike, text: string): AskAnswerValue; /** * Answer the host's user-question and approval waterfalls from the chat * channel. Registered once, eagerly, at construction time. */ export declare class InteractionBridge { private readonly ctx; private readonly adapters; private readonly bindings; private readonly config; /** * Is a turn currently being driven for this (channel, chatKey)? * * This is how a question finds the client the user is actually standing in. The * request carries no origin of its own, so the only reliable signal is which runner is * mid-turn right now: send from the Web GUI and the web runner is busy; send from * Feishu and the feishu one is. Without it the card went to whichever binding the * store listed first, which meant a question raised from the PC could appear in * Feishu — where the user could not answer it, and the turn blocked. */ private readonly isDriving; private readonly pending; constructor(ctx: Context, adapters: ReadonlyMap, bindings: BindingStore, config: ResolvedConnectConfig, /** * Is a turn currently being driven for this (channel, chatKey)? * * This is how a question finds the client the user is actually standing in. The * request carries no origin of its own, so the only reliable signal is which runner is * mid-turn right now: send from the Web GUI and the web runner is busy; send from * Feishu and the feishu one is. Without it the card went to whichever binding the * store listed first, which meant a question raised from the PC could appear in * Feishu — where the user could not answer it, and the turn blocked. */ isDriving?: (channel: string, chatKey: string) => boolean); /** Register one waterfall listener, tolerating a host without the event API. */ private register; /** Whether a question/approval is waiting on an answer in this chat. */ pendingFor(chatKey: string): boolean; /** Deliver a plain-text chat reply as the answer to the pending question. */ answerText(chatKey: string, text: string): boolean; private answerQuestion; /** Ask every question in turn. `undefined` = the flow was interrupted. */ private runQuestions; /** * Record a tap that belongs to an *earlier* question of this same interaction. * * The card is reused across questions, so a tap can arrive after the card has * already advanced: the user aims at the option they can still see, while the * bridge has moved on to the next question. Treating that as "unrecognised" * drops a real answer and leaves the flow waiting on a question the user * believes they just answered. * * @returns true when the choice matched some question in this interaction. */ private answerFromStaleCard; private askOne; /** Options-free question: the user answers by sending an ordinary message. */ private askByText; /** * Wait until an answer is recorded, the flow stops, or `timeoutMs` elapses. * Resolves (never rejects) and always removes its own listener and timer. */ private waitForAnswer; /** * A one-line-per-question recap of what was answered, for the closed card. * * The card is replaced, so without this the user's choices disappear the moment * they finish — leaving them unable to confirm what was sent, which is exactly * the moment they are most likely to want to check. */ private questionSummaries; private questionPromptText; private recordAnswer; private answerApproval; /** * Pick the one chat that owns `sessionId` and can actually present a prompt. * * Only the first match is claimed: two cards for one question would leave the * second one unanswerable (the answer is delivered to the agent, not to a * specific card), and the Web GUI would render a duplicate. * * The `web` channel is a GUI mirror whose outbound face is a no-op by design * — it can neither show a card nor report a tap, so claiming it would spin * the re-present loop forever. Declining it leaves the request to the host's * own forwarder, which is precisely the Web GUI. */ private claimTarget; /** Stop presenting and drop the interaction (answered, cancelled, or torn down). */ private settle; private outbound; private log; } export {}; //# sourceMappingURL=interaction.d.ts.map