diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index c1bb6ffde..cb325fb04 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -174,3 +174,5 @@ jobs: -run '^TestPostgresRuntimeFence' ./internal/chat/message TEST_POSTGRES_BOOTSTRAP_SCHEMA=0 go test -v -race -count=1 -timeout 90s \ -run '^TestPostgresRuntimeFence' ./internal/agent/runtime/acp + TEST_POSTGRES_BOOTSTRAP_SCHEMA=0 go test -v -race -count=1 -timeout 90s \ + ./internal/agent/runtime/session/ledger diff --git a/apps/web/src/composables/api/useChat.chat-api.ts b/apps/web/src/composables/api/useChat.chat-api.ts index d11087eee..31e800d23 100644 --- a/apps/web/src/composables/api/useChat.chat-api.ts +++ b/apps/web/src/composables/api/useChat.chat-api.ts @@ -17,8 +17,23 @@ import { patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode, patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel, patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning, + getBotsByBotIdSessionsBySessionIdQueue, + postBotsByBotIdSessionsBySessionIdSteerQueue, + postBotsByBotIdSessionsBySessionIdFollowUpQueue, + postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer, + putBotsByBotIdSessionsBySessionIdSteerQueueReorder, + putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder, + patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId, + patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, + deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId, + deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, +} from '@memohai/sdk' +import type { + AcpagentRuntimeStatus, + HandlersFollowUpQueueItemResponse, + HandlersSessionQueueResponse, + HandlersSteerQueueItemResponse, } from '@memohai/sdk' -import type { AcpagentRuntimeStatus } from '@memohai/sdk' import type { Bot, SessionSummary } from './useChat.types' export interface CreateSessionOptions { @@ -51,6 +66,99 @@ export interface CreateACPRuntimeOptions { projectPath?: string } +/** + * The two queues share one wire shape for the fields the composer renders. + * The server keeps them as separate response types; the union here is only a + * read-side convenience and never crosses back into a request. + */ +export type SessionQueueItem = Pick< + HandlersSteerQueueItemResponse & HandlersFollowUpQueueItemResponse, + 'item_id' | 'status' | 'position' | 'text' +> + +export type SessionQueuesResponse = HandlersSessionQueueResponse + +export function queueItemText(item: SessionQueueItem): string { + return item.text ?? '' +} + +const queuePath = (botId: string, sessionId: string) => ({ bot_id: botId.trim(), session_id: sessionId.trim() }) + +export async function enqueueSteerQueue(botId: string, sessionId: string, text: string, invocationId = crypto.randomUUID()): Promise { + const { data } = await postBotsByBotIdSessionsBySessionIdSteerQueue({ + path: queuePath(botId, sessionId), + body: { invocation_id: invocationId, text }, + throwOnError: true, + }) + return data +} + +export async function fetchSessionQueues(botId: string, sessionId: string): Promise { + const { data } = await getBotsByBotIdSessionsBySessionIdQueue({ path: queuePath(botId, sessionId), throwOnError: true }) + return data ?? {} +} + +export async function enqueueFollowUpQueue(botId: string, sessionId: string, text: string, invocationId = crypto.randomUUID()): Promise { + const { data } = await postBotsByBotIdSessionsBySessionIdFollowUpQueue({ + path: queuePath(botId, sessionId), + body: { invocation_id: invocationId, text }, + throwOnError: true, + }) + return data +} + +export async function promoteFollowUpQueueItemToSteer(botId: string, sessionId: string, itemId: string): Promise { + const { data } = await postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer({ + path: { ...queuePath(botId, sessionId), item_id: itemId.trim() }, + throwOnError: true, + }) + return data +} + +export async function updateSteerQueueItem(botId: string, sessionId: string, itemId: string, text: string): Promise { + const { data } = await patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId({ + path: { ...queuePath(botId, sessionId), item_id: itemId.trim() }, + body: { text }, + throwOnError: true, + }) + return data +} + +export async function updateFollowUpQueueItem(botId: string, sessionId: string, itemId: string, text: string): Promise { + const { data } = await patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId({ + path: { ...queuePath(botId, sessionId), item_id: itemId.trim() }, + body: { text }, + throwOnError: true, + }) + return data +} + +export async function deleteSteerQueueItem(botId: string, sessionId: string, itemId: string): Promise { + await deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId({ path: { ...queuePath(botId, sessionId), item_id: itemId.trim() }, throwOnError: true }) +} + +export async function deleteFollowUpQueueItem(botId: string, sessionId: string, itemId: string): Promise { + await deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId({ path: { ...queuePath(botId, sessionId), item_id: itemId.trim() }, throwOnError: true }) +} + +export async function reorderSteerQueue(botId: string, sessionId: string, itemId: string, beforeId: string): Promise { + const { data } = await putBotsByBotIdSessionsBySessionIdSteerQueueReorder({ + path: queuePath(botId, sessionId), + body: { item: { item_id: itemId }, before: { item_id: beforeId } }, + throwOnError: true, + }) + return data?.items ?? [] +} + +export async function reorderFollowUpQueue(botId: string, sessionId: string, itemId: string, beforeId: string): Promise { + const { data } = await putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder({ + path: queuePath(botId, sessionId), + body: { item: { item_id: itemId }, before: { item_id: beforeId } }, + throwOnError: true, + }) + return data?.items ?? [] +} + export async function fetchBots(): Promise { const { data } = await getBots({ throwOnError: true }) return data?.items ?? [] diff --git a/apps/web/src/composables/api/useChat.types.ts b/apps/web/src/composables/api/useChat.types.ts index 5ac3b78ff..8385374fc 100644 --- a/apps/web/src/composables/api/useChat.types.ts +++ b/apps/web/src/composables/api/useChat.types.ts @@ -409,14 +409,6 @@ export interface RuntimeCursor { seq: number } -export interface RuntimeSteerState { - id: string - status: string - text?: string - error?: string - created_at: string - updated_at: string -} export interface RuntimeRunOperation { kind: 'retry' | 'edit' @@ -438,14 +430,29 @@ export interface RuntimeCurrentRunView { updated_at: string messages: UIMessage[] request_user_turn?: UIUserTurn + // Ordered inputs already admitted into this run. The first entry is the + // request turn when present; later entries are applied steers. + user_turns?: UIUserTurn[] + // Live steer claims projected at their exact assistant-message + // boundary. Claimed entries are provisional; applied entries reference the + // settled history turn that replaces them. + steer_turns?: RuntimeSteerTurnView[] error_code?: string error?: string proposed_terminal_status?: RuntimeRunStatus finish_proposed_at?: string - steer?: RuntimeSteerState operation?: RuntimeRunOperation } +export interface RuntimeSteerTurnView { + item_id: string + status: 'claimed' | 'applied' + text: string + turn_id?: string + after_message_id: number + timestamp: string +} + export interface RuntimeSnapshot { bot_id: string session_id: string @@ -460,7 +467,6 @@ export interface RuntimeCurrentRunPatch { status?: RuntimeRunStatus error_code?: string error?: string - steer?: RuntimeSteerState updated_at?: string owner_lease_expires_at?: string } @@ -480,6 +486,9 @@ export interface RuntimeProgressAppend { export interface RuntimeDelta { current_run_view?: RuntimeCurrentRunView run?: RuntimeCurrentRunPatch + user_turn_upserts?: UIUserTurn[] + steer_turn_upserts?: RuntimeSteerTurnView[] + steer_turn_removals?: string[] message_appends?: RuntimeMessageAppend[] progress_appends?: RuntimeProgressAppend[] message_upserts?: UIMessage[] diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 354a78e07..5b5f3897c 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -112,6 +112,9 @@ "finish": "Done" }, "errors": { + "queue": { + "steer_unsupported": "This run cannot accept steer input. Wait for it to finish and send a new message." + }, "bot": { "name_taken": "This name is already taken." }, @@ -141,6 +144,10 @@ "response_timeout": "The model did not respond in time. Please try again.", "response_interrupted": "The model response was interrupted. Please try again." }, + "queue_no_active_run": "The current response has ended. The message is still queued.", + "queue_admission_overloaded": "The queue is busy. Please retry shortly.", + "queue_admission_unavailable": "Queue admission is temporarily unavailable. Please retry shortly.", + "queue_item_not_pending": "This message is no longer pending in the queue.", "profile": { "request_invalid": "The profile update request is invalid.", "title_model_invalid": "The selected title model is unavailable or is not a chat model.", @@ -695,6 +702,23 @@ "fragmentSeparator": ", " }, "currentBot": "Current Bot", + "queue": { + "steer": "Current response", + "followUp": "Up next", + "mode": "Send as", + "steerDescription": "Add to the current response at the next safe step", + "followUpDescription": "Send automatically after the current response", + "steerPlaceholder": "Add something to the current response…", + "followUpPlaceholder": "Write the next message…", + "enqueueSteer": "Add to current response", + "enqueueFollowUp": "Add to up next", + "steerQueued": "Added to the current response queue", + "steerFailed": "Could not steer the current response. The message is still queued.", + "reorder": "Reorder", + "remove": "Remove", + "switchToFollowUp": "Queue after this run", + "switchToSteer": "Steer the current run" + }, "inputPlaceholder": "Ask anything", "readonlyHint": "This chat is read-only", "readonlyPlaceholder": "This chat is read-only. Sending messages is disabled.", diff --git a/apps/web/src/i18n/locales/ja.json b/apps/web/src/i18n/locales/ja.json index e472928b5..d523588e3 100644 --- a/apps/web/src/i18n/locales/ja.json +++ b/apps/web/src/i18n/locales/ja.json @@ -109,6 +109,9 @@ "finish": "完了" }, "errors": { + "queue": { + "steer_unsupported": "現在の実行にはメッセージを追加できません。終了してから新しいメッセージを送信してください。" + }, "bot": { "name_taken": "この名前はすでに使用されています。" }, @@ -138,6 +141,10 @@ "response_timeout": "モデルから時間内に応答がありませんでした。もう一度お試しください。", "response_interrupted": "モデルの応答が中断されました。もう一度お試しください。" }, + "queue_no_active_run": "現在の応答は終了しました。メッセージはキューに残っています。", + "queue_admission_overloaded": "キューが混雑しています。しばらくしてから再試行してください。", + "queue_admission_unavailable": "キューサービスは一時的に利用できません。しばらくしてから再試行してください。", + "queue_item_not_pending": "このメッセージは待機中のキューにありません。", "profile": { "request_invalid": "プロフィール更新リクエストが無効です。", "title_model_invalid": "選択したタイトルModelは利用できないか、チャットModelではありません。", @@ -679,6 +686,23 @@ "fragmentSeparator": "、" }, "currentBot": "現在のBot", + "queue": { + "steer": "現在の応答", + "followUp": "次のメッセージ", + "mode": "送信方法", + "steerDescription": "次の安全なステップで現在の応答に追加", + "followUpDescription": "現在の応答が終わった後に自動送信", + "steerPlaceholder": "現在の応答に追加する内容を入力…", + "followUpPlaceholder": "次のメッセージを入力…", + "enqueueSteer": "現在の応答に追加", + "enqueueFollowUp": "次のメッセージに追加", + "steerQueued": "現在の応答キューに追加済み", + "steerFailed": "現在の応答に追加できませんでした。メッセージはキューに残っています。", + "reorder": "並べ替え", + "remove": "削除", + "switchToFollowUp": "この実行の後に追加", + "switchToSteer": "現在の実行に追加" + }, "inputPlaceholder": "質問を入力してください", "readonlyHint": "このチャットは読み取り専用です", "readonlyPlaceholder": "このチャットは読み取り専用です。メッセージの送信は無効になっています。", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index f1ceaad4f..f109f9915 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -112,6 +112,9 @@ "finish": "完成" }, "errors": { + "queue": { + "steer_unsupported": "当前运行不支持插入消息,请等它结束后再发送。" + }, "bot": { "name_taken": "该名称已被占用。" }, @@ -141,6 +144,10 @@ "response_timeout": "模型未能及时响应,请重试。", "response_interrupted": "模型响应意外中断,请重试。" }, + "queue_no_active_run": "当前回复已经结束,消息仍保留在队列中。", + "queue_admission_overloaded": "队列当前繁忙,请稍后重试。", + "queue_admission_unavailable": "队列服务暂时不可用,请稍后重试。", + "queue_item_not_pending": "这条消息已不在待处理队列中。", "profile": { "request_invalid": "个人资料更新请求无效。", "title_model_invalid": "所选标题模型不可用或不是聊天模型。", @@ -695,6 +702,23 @@ "fragmentSeparator": "、" }, "currentBot": "当前 Bot", + "queue": { + "steer": "当前回复", + "followUp": "接下来", + "mode": "发送方式", + "steerDescription": "在下一个安全步骤加入当前回复", + "followUpDescription": "当前回复结束后自动发送", + "steerPlaceholder": "输入要插入当前回复的内容…", + "followUpPlaceholder": "输入下一条消息…", + "enqueueSteer": "插入当前回复", + "enqueueFollowUp": "添加到接下来", + "steerQueued": "已加入当前回复队列", + "steerFailed": "无法插入当前回复,消息仍保留在队列中。", + "reorder": "调整顺序", + "remove": "删除", + "switchToFollowUp": "排到本次运行之后", + "switchToSteer": "插入当前运行" + }, "inputPlaceholder": "问点什么", "readonlyHint": "该聊天为只读", "readonlyPlaceholder": "该聊天为只读,无法发送消息", diff --git a/apps/web/src/pages/home/components/chat-pane.vue b/apps/web/src/pages/home/components/chat-pane.vue index 1df15caa5..d883e9e41 100644 --- a/apps/web/src/pages/home/components/chat-pane.vue +++ b/apps/web/src/pages/home/components/chat-pane.vue @@ -410,6 +410,13 @@ :class="isWelcome ? 'min-h-28 p-3' : 'p-2.5 chat-composer-docked'" @click="handleComposerClick" > + rejected (error_code set) + +----------------> canceled +``` + +- `accepted`: the item is stored and pending. Only accepted items are listed, + reordered, edited, or canceled. +- `claimed`: a consumer holds the item. A steer claim carries the run ID, + owner, generation, fencing token, and a claim token; a follow-up claim carries + the terminal run that triggered it and a claim token. +- `applied`: the item entered a model step whose history commit succeeded + (steer), or started its continuation run (follow-up). +- `rejected`: the runtime refused the item after acceptance. `ErrorCode` names + the stable reason; today the only reason is `queue_target_run_not_active`. +- `canceled`: the caller withdrew a pending item. Promoting a follow-up to a + steer cancels the follow-up and creates a new steer item. + +`expired` exists in the status vocabulary but no path writes it. + +Invocation IDs provide best-effort replay protection for the lifetime of the +retained items: the same invocation with the same payload returns the existing +item, and a different payload returns `ErrQueueInvocationConflict`. + +## Capacity and compaction + +Each queue document is bounded in two ways: + +- At most `MaxPendingQueueItems` (64) accepted items per queue and session. + Enqueue beyond that returns `ErrQueueCapacityExceeded`, surfaced as + `queue_capacity_exceeded` over HTTP and channel slash commands. +- After every mutation the document keeps all accepted and claimed items and + only the newest 64 terminal items. Map entries that reference dropped items + (promotion records, per-run follow-up claims) are removed with them. + +Replay protection therefore covers roughly the last 64 completed submissions. + +## Steer + +A steer is bound to the run that was active when it was admitted. + +History keeps its existing turn model: every user message opens a turn, and +the assistant and tool rows that answer it belong to that turn. An applied +steer is therefore persisted as its own turn, and the output that follows it +is filed under that turn, while the run ID does not change. A run can span +several turns; the live projection names the post-steer assistant segment +after the steer's turn as soon as that turn is known. + +- Admission records the active run as `TargetRunID`. Without an active run, + or after the run has been sealed, admission returns `ErrQueueNoActiveRun`. +- Native streaming admission/promotion wakes the fenced owner through the + existing command transport. Notifications coalesce locally; pending queue + state remains authoritative. Each provider admission also checks that state. +- During model sampling (including waiting for response headers), steer cancels + only the current invocation. Once its SDK stream is quiescent, the application + persists an interrupted checkpoint, applies any input in that checkpoint, + and claims the next input. Execution continues with the same run and an + advanced step cursor, without another agent-start or a run-abort event. + Unfinished reasoning is projected as text rather than replaying incomplete + provider signatures. Failure to quiesce or persist fails the run safely. +- The interruption gate closes before the SDK receives tool-call output or + finish-step. Already admitted tools and decisions are not cancelled by steer; + they keep their normal result/approval lifecycle before input is consumed. +- At each committed step the application applies the previously claimed steer + and claims the next accepted steer for the same run. During a tool loop the + claimed text is injected into the next model request; at a final step the + claim reopens the same run with the steer as the next model input. +- A step that parks the run for a tool approval or user input applies the + previous claim but does not claim a new one. The resumed invocation claims + at its next committed step or interruption checkpoint, so no claim waits + unapplied across the decision. +- `ClaimNextSteer` returns the run's existing unapplied claim before selecting + a new item. When the run has been reclaimed by a new owner, the stored claim + is advanced to the new owner, generation, and fencing token; the previous + owner's reference no longer matches it. +- A final step that finds no steer seals the run (`ClosedRunID`), so a steer + that arrives between the final commit and the terminal record is refused. +- When the run reaches any terminal state the terminal observer calls + `CloseSteerRun`: every accepted or claimed steer targeting the run becomes + `rejected` with `queue_target_run_not_active`, and the run is sealed. + +A claim is valid only for the run's current owner, generation, and fencing +token; applying with a stale claim returns `ErrRunOwnershipLost`. + +### Codex comparison + +Reference: OpenAI Codex commit +[`1530f828cbaea015bc0fc53c0486e2f889a677f3`](https://github.com/openai/codex/tree/1530f828cbaea015bc0fc53c0486e2f889a677f3). +Its `codex_thread.rs::steer_turn` requires the expected active turn and cannot +start another turn. `session/turn_input.rs::steer_input` appends input atomically +to the active task; `session/input_queue.rs` exposes queue activity notifications. +The public `turn/steer` contract is distinct from `turn/interrupt`: +https://developers.openai.com/codex/app-server#steer-an-active-turn. + +This is not a claim that public Codex always cancels an in-flight HTTP request: +`core/tests/suite/pending_input.rs::user_input_does_not_preempt_after_reasoning_item` +explicitly preserves the original response and tool call. Memoh follows the +same run identity and safe tool boundaries, and additionally implements the +requested force-steer behavior during native model sampling. External drivers +and provider-specific Responses WebSocket steering are outside this mechanism. + +## Follow-up + +A follow-up is bound to the session. It records the run that was active when +it was enqueued (`EnqueuedDuringRunID`) but is consumed by whichever run +finishes next. + +Two producers share the queue: + +- The queue panel and the `/queue` slash command store `{"text": ...}`. The + continuation starts as an ordinary chat turn on the same session. `/queue` + is accepted only on local channel types (web, cli); a platform channel gets + `queue_follow_up_unsupported_channel`, because it could not receive the + reply of a run the server starts from the queue. `/steer` stays available on + every channel: it joins the run whose reply the channel is already + streaming. +- A complete turn that met a busy session (`turn.ErrSessionBusy`) may be + stored through `EnqueueDeferredTurn` as `{"text": ..., "command": ...}` with + the full `StartTurnCommand`. The continuation keeps the route, reply target, + attachments, and metadata of the original message. The caller sees + `turn.ErrTurnDeferred`; if the run ended before the enqueue, the caller sees + `ErrQueueNoActiveRun` and retries ordinary admission. + Only the local channel types (web, cli) do this: their users observe the + resulting run through the session runtime subscription. Platform channels + deliver replies by streaming the caller's run handle, and a run started from + the queue has no such consumer, so they keep the bounded busy retry and + surface `ErrSessionBusy` when it expires. `StartTurn` itself never defers. + +Consumption: + +- The terminal observer claims the oldest accepted follow-up for the finished + run and starts it through normal turn admission with `NoDefer` set and the + retry identity `follow-up:`. Admission remains the only owner and + fencing authority; the queue only selects payload. +- One starter runs per session at a time. A successful start applies the + claim; a failed start releases it so the next terminal boundary claims it + again. Ordinary user turns may win the session slot first; the follow-up + then waits for that run to end. +- An enqueue that observed an active run re-checks the live snapshot after + writing. If the run has already ended, the enqueue path starts the follow-up + itself, so an item cannot wait for an unrelated later run. +- Follow-ups are not rejected when the run they were queued behind aborts, + fails, or is lost. The queued input still belongs to the session; only + steers, which are run-bound, are rejected at terminal. + +## Redis transactions + +Queue mutations run as `WATCH`/`MULTI` transactions whose watch set is the +queue document plus, where a decision depends on ownership, the run key that +the finishing owner deletes. The session state key is read inside the +transaction but never watched: it is rewritten on every streamed runtime delta +and watching it would fail queue transactions during normal output. A steer +admitted against a snapshot that turns terminal is still closed by +`CloseSteerRun`, which serializes on the queue document. + +Conflicting transactions retry with exponential backoff up to eight times and +then return `ErrQueueAdmissionOverloaded`. + +## PostgreSQL boundary + +PostgreSQL remains authoritative for ordinary run admission, ownership and +fencing, history, user-input/approval state, and other durable application +records. It does not store queue payloads, queue claims, follow-up +continuation provenance, or queue step-commit records. The queue feature was +never added to the canonical schema or migration chain; deployments upgrade +directly from the existing `0145` schema. + +## Recovery and availability + +Because queues are live state, a process restart with the memory backend (or a +Redis data loss event) may leave no pending item to recover. This is an explicit +availability trade-off for low-latency input handling. Normal run/history +durability and fencing are unaffected. Clients should treat queue errors as +runtime availability errors and retry with a new invocation ID only when the +original result was not observed. diff --git a/docs/design/session-runtime-requirements.md b/docs/design/session-runtime-requirements.md index 83d77a49c..6640d0298 100644 --- a/docs/design/session-runtime-requirements.md +++ b/docs/design/session-runtime-requirements.md @@ -167,11 +167,17 @@ run 进入终态时,终态、最终输出和 turn 投影必须形成一个一 - owner 进程退出、租约到期、live backend 更换或优雅关机时,reaper/关闭流程必须把提案收敛为其原始 `completed`、`aborted` 或 `failed`,不能改写为 `lost`; - 只有从未跨过终态提案边界的 active run 才能因 owner 消失进入 `lost`。 -ledger 成为终态后,系统必须以该 durable outcome 修复可能滞后的 live 投影;修复必须校验 live run ref 中保存的同一个 fencing token,不能覆盖后继 run。 +ledger 成为终态后,系统必须以该 durable outcome 修复可能滞后的 live 投影;修复必须原子校验 live snapshot 中保存的同一个 fencing token(旧 snapshot 仅在 lease ref 尚存时可使用其中的 token),不能覆盖后继 run。 ### SR-TURN-001:turn 必须是显式身份 -每个已准入 run 必须显式关联一个服务端生成的 `turn_id`。用户消息、Agent 输出、工具事件和决策都通过该身份归属到同一个 turn。 +每个已准入 run 必须显式关联一个服务端生成的起始 `turn_id`。普通 run 的用户消息与回答归于该 turn。已应用的 steer 是同一 run 中新的用户输入,可以打开新的 canonical turn;该输入之后的 Agent 输出和工具消息归于新 turn,所有这些 turn 通过显式 `run_id` 关联,不能重新准入第二个 run。 + +run 控制记录及其 decision 的 `turn_id` 保持起始 turn 身份,用于 owner/fence 与决策恢复校验;聊天消息的 `turn_id` 表示该消息所属的 canonical turn。客户端提交决策以 `decision_id` 和 run 身份为准,不能把临时渲染 ID 或某个显示分段的 ID 当作 run 控制身份。steer 的用户消息必须进入实际 provider 请求,并与对应完整 step 或被后续 steer 中断的 checkpoint 一起持久化,不能只在实时投影中展示。 + +Native 流式执行的 steer 必须能中断正在生成文本/推理或等待响应的模型调用,保存有效 checkpoint 后,在原 run 内加入新指令续跑。不能依赖测试先释放原模型才能消费。队列 `accepted` 只代表接收输入;owner 唤醒命令只代表控制信号送达,均不等于输入已应用。已接收的工具调用、工具执行和决策停等保持安全边界:不因 steer 取消或重复执行工具,不跳过审批;到下一次模型调用时优先处理新输入。用户 abort、owner 丢失和 `finishing` 的规则不变,steer 不得重新准入 run 或复活已终止的 run。 + +决策停等后,同一 owner 的续跑必须接续其已消费的 step 游标;owner 更换后游标可随 generation 重建。该游标仅服务进程内排序与投影屏障,不宣称跨进程模型采样重放。 实现不能根据以下字段是否相似来决定两条消息属于同一 turn: @@ -242,7 +248,9 @@ Redis 或 Valkey 不应成为单实例 OSS 部署的强制依赖。 | terminal proposal crash | 故障注入/Server 重启 | `finishing` 在 owner 消失后收敛到原提案,不能变成 `lost` | SR-DUR-002、SR-OWN-002 | | decision restart | Server 重启 | decision 可恢复,回答只消费一次 | SR-DEC-001 | -当前黑盒验收代码覆盖 `baseline`、`reconnect snapshot`、`reconnect abort`、`duplicate invocation`、`same-session concurrency` 和 `owner crash`。包内测试使用故障注入覆盖终态提案重试、owner 租约到期、live backend 更换和优雅关机;`concurrent subscribers`、完整进程级 `terminal proposal crash` 与 `decision restart` 仍需补充黑盒用例。 +当前黑盒代码包含基础执行、重连、多个订阅者、控制/决策重放、重复准入、busy、owner crash、decision restart、live backend loss,以及队列排序/连续消费和 steer 后决策续跑。`terminal proposal crash` 使用隔离数据库的单 invocation advisory-lock trigger,在已提交提案与最终状态更新之间阻塞,然后终止真实 owner 进程。 + +owner/decision/terminal-proposal crash 用例由 `MEMOH_SESSION_RUNTIME_ACCEPTANCE_CRASH` 显式启用;backend-loss 用例另有开关。用例存在、编译通过或因未启用而跳过,不代表该验收已执行通过。 ## 7. 通过标准 diff --git a/internal/agent/adapter/channelqueue/adapter.go b/internal/agent/adapter/channelqueue/adapter.go new file mode 100644 index 000000000..8e51b4013 --- /dev/null +++ b/internal/agent/adapter/channelqueue/adapter.go @@ -0,0 +1,70 @@ +// Package channelqueue adapts channel queue controls to the application +// service without exposing queue storage types to channels or RPC. +package channelqueue + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/felinics/memoh/internal/agent/application" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + "github.com/felinics/memoh/internal/channel/inbound" +) + +type Adapter struct{ service *application.Service } + +func New(service *application.Service) *Adapter { return &Adapter{service: service} } + +func (a *Adapter) EnqueueSteer(ctx context.Context, input inbound.QueueCommandInput) error { + return a.enqueue(ctx, input, func(payload []byte) error { + _, err := a.service.EnqueueSteer(ctx, input.BotID, input.SessionID, input.InvocationID, payload) + return err + }) +} + +func (a *Adapter) EnqueueFollowUp(ctx context.Context, input inbound.QueueCommandInput) error { + return a.enqueue(ctx, input, func(payload []byte) error { + _, err := a.service.EnqueueFollowUp(ctx, input.BotID, input.SessionID, input.InvocationID, payload) + return err + }) +} + +func (a *Adapter) enqueue(_ context.Context, input inbound.QueueCommandInput, admit func([]byte) error) error { + if a == nil || a.service == nil { + return inbound.NewQueueCommandError(inbound.QueueCommandCodeUnavailable) + } + if strings.TrimSpace(input.BotID) == "" || strings.TrimSpace(input.SessionID) == "" || + strings.TrimSpace(input.InvocationID) == "" || strings.TrimSpace(input.Text) == "" { + return inbound.NewQueueCommandError(inbound.QueueCommandCodeInvalid) + } + payload, err := json.Marshal(map[string]string{"text": strings.TrimSpace(input.Text)}) + if err != nil { + return inbound.NewQueueCommandError(inbound.QueueCommandCodeInvalid) + } + return mapAdmissionError(admit(payload)) +} + +func mapAdmissionError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, sessionruntime.ErrQueueSteerUnsupported): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeUnsupported) + case errors.Is(err, sessionruntime.ErrQueueNoActiveRun): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeNoActiveRun) + case errors.Is(err, sessionruntime.ErrQueueInvocationConflict): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeConflict) + case errors.Is(err, sessionruntime.ErrQueueAdmissionOverloaded): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeOverloaded) + case errors.Is(err, sessionruntime.ErrQueueCapacityExceeded): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeCapacity) + case errors.Is(err, sessionruntime.ErrQueueInvalidReference): + return inbound.NewQueueCommandError(inbound.QueueCommandCodeInvalid) + default: + return err + } +} + +var _ inbound.QueueCommandHandler = (*Adapter)(nil) diff --git a/internal/agent/application/contract.go b/internal/agent/application/contract.go index 954a5e1d3..f1735baec 100644 --- a/internal/agent/application/contract.go +++ b/internal/agent/application/contract.go @@ -3,7 +3,9 @@ package application import ( "encoding/json" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/agent/turn" + messagepkg "github.com/felinics/memoh/internal/chat/message" ) // ChatRequest is the application-layer input used while orchestrating a chat @@ -21,6 +23,9 @@ type ChatRequest struct { // that must agree on "which turn is this" — the compaction barrier, the ACP // session, interactive tool headers — keys on it. RunID string `json:"-"` + // RunHandle is the server-owned execution capability for durable step and + // queue commits. Transport callers cannot supply it. + RunHandle sessionruntime.RunHandle `json:"-"` // TurnID and TurnPosition are the turn admission already allocated for this // run. They travel with the request so the persisted user turn lands under // the id the client was handed at run_accepted, instead of the history layer @@ -63,8 +68,12 @@ type ChatRequest struct { RuntimeType string `json:"-"` SkipMemoryExtraction bool `json:"-"` SkipHistoryTurn bool `json:"-"` - SkipTitleGeneration bool `json:"-"` - ForceFreshRuntime bool `json:"-"` + // TurnReplacement is set only for an admitted retry/edit run. Its step + // output stays hidden until the queue coordinator reaches the true final + // boundary and publishes this replacement in its own transaction. + TurnReplacement *messagepkg.TurnReplacement `json:"-"` + SkipTitleGeneration bool `json:"-"` + ForceFreshRuntime bool `json:"-"` // AgentCommand is the exact agent-command selector the Web admission layer // matched against a live ACP runtime. The session pool re-validates it // against the final session at prompt time; it never crosses the turn @@ -81,6 +90,13 @@ type ChatRequest struct { // InjectCh receives user messages between tool rounds. Remote transports // use turn.RunHandle.Inject instead. InjectCh <-chan turn.InjectMessage `json:"-"` + // QueueSteerEnabled enables the fenced native queue consumer. + QueueSteerEnabled bool `json:"-"` + StepIndexOffset int `json:"-"` + // PublishRuntimeEvents is set for server-owned continuations, which do not + // have a client runHandle pump to publish native events into the session + // runtime projection. + PublishRuntimeEvents bool `json:"-"` Query string `json:"query"` Model string `json:"model,omitempty"` diff --git a/internal/agent/application/decision_output_test.go b/internal/agent/application/decision_output_test.go index 0dbfe231d..759284af4 100644 --- a/internal/agent/application/decision_output_test.go +++ b/internal/agent/application/decision_output_test.go @@ -11,7 +11,7 @@ import ( ) type decisionOutputBackend struct { - sessionruntime.Backend + *sessionruntime.MemoryBackend output []struct { Type string Output json.RawMessage @@ -19,7 +19,7 @@ type decisionOutputBackend struct { } func (b *decisionOutputBackend) AppendDecisionOutput(ctx context.Context, ref sessionruntime.DecisionOutputRef, seq int64, payload json.RawMessage, limits sessionruntime.DecisionOutputLimits) (sessionruntime.DecisionOutputState, error) { - state, err := b.Backend.AppendDecisionOutput(ctx, ref, seq, payload, limits) + state, err := b.MemoryBackend.AppendDecisionOutput(ctx, ref, seq, payload, limits) if err == nil && state.Applied { entry := struct { Type string @@ -36,7 +36,7 @@ func (b *decisionOutputBackend) AppendDecisionOutput(ctx context.Context, ref se func TestContinuationPublishesTextAndNextQuestionToChannel(t *testing.T) { for _, nextQuestion := range []bool{false, true} { t.Run(map[bool]string{false: "ordinary reply", true: "second question"}[nextQuestion], func(t *testing.T) { - backend := &decisionOutputBackend{Backend: sessionruntime.NewMemoryBackend()} + backend := &decisionOutputBackend{MemoryBackend: sessionruntime.NewMemoryBackend()} manager, handle := newWaitingDecisionRuntime(t, backend) service := &Service{decisionRuntime: manager} events := []native.StreamEvent{{Type: native.EventAgentStart}, {Type: native.EventTextDelta, Delta: "收到你的答案"}} @@ -72,13 +72,13 @@ func TestContinuationPublishesTextAndNextQuestionToChannel(t *testing.T) { } } -type failedEndCheckpointBackend struct{ sessionruntime.Backend } +type failedEndCheckpointBackend struct{ *sessionruntime.MemoryBackend } func (b failedEndCheckpointBackend) AppendDecisionOutput(ctx context.Context, ref sessionruntime.DecisionOutputRef, seq int64, payload json.RawMessage, limits sessionruntime.DecisionOutputLimits) (sessionruntime.DecisionOutputState, error) { if payload == nil { return sessionruntime.DecisionOutputState{}, errors.New("checkpoint write unavailable") } - return b.Backend.AppendDecisionOutput(ctx, ref, seq, payload, limits) + return b.MemoryBackend.AppendDecisionOutput(ctx, ref, seq, payload, limits) } func TestContinuationClosesRunWhenEndCheckpointCannotPersist(t *testing.T) { diff --git a/internal/agent/application/native_decision_continuation.go b/internal/agent/application/native_decision_continuation.go new file mode 100644 index 000000000..f644baf86 --- /dev/null +++ b/internal/agent/application/native_decision_continuation.go @@ -0,0 +1,171 @@ +package application + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + + "github.com/felinics/memoh/internal/agent/runtime/native" + "github.com/felinics/memoh/internal/models" +) + +// runNativeDecisionContinuation owns the model stream after a committed answer. +// Authorization and answer/tool-result persistence remain with each decision +// kind; cancellation, step persistence and terminal publication share one path. +func (s *Service) runNativeDecisionContinuation(ctx context.Context, req ChatRequest, cfg native.RunConfig, modelID string, runtimeLifecycle *continuationLifecycleResult, eventCh chan<- WSStreamEvent) error { + terminal := s.contextLifecycleTerminal(ctx, cfg) + var lifecycleCause error + var lifecycleDeferred bool + var terminalEventSeen bool + defer func() { + if runtimeLifecycle != nil { + runtimeLifecycle.cause = lifecycleCause + runtimeLifecycle.deferred = lifecycleDeferred + if snapshot, ok := cfg.ContextLifecycle.Snapshot(); ok { + runtimeLifecycle.snapshot = &snapshot + } + return + } + if !lifecycleDeferred { + terminal(lifecycleCause) + } + }() + + continuationRC := resolvedContext{runConfig: cfg, model: models.GetResponse{ID: modelID}} + stepCommitter, err := s.bindQueueContinuation(ctx, &req, &cfg, continuationRC) + if err != nil { + return err + } + reasoningTiming := newReasoningTimingTracker(nil) + configureNativeReasoningTiming(&cfg, reasoningTiming, stepCommitter) + idleCtx, idleCancel := s.withStreamIdleTimeout(ctx, reasoningEffortForIdle(cfg)) + defer idleCancel.Stop() + stream := s.agent.Stream(idleCtx, cfg) + stored := false + failureEventForwarded := false + var hasVisibleOutput bool + for event := range stream { + idleCancel.Reset() + if event.Type == native.EventToolCallStart { + idleCancel.RecordToolCall() + } + if eventErr := agentStreamLifecycleError(event); eventErr != nil && lifecycleCause == nil { + lifecycleCause = eventErr + // The public event forwarded downstream carries only a stable code; + // keep the runtime's private detail in the server log so a failed + // continuation can be diagnosed. + s.logContinuationStreamError(req.RunID, event) + } + if event.IsTerminal() { + terminalEventSeen = true + lifecycleDeferred = pendingContinuationDecision(event) + if !lifecycleDeferred { + switch event.Type { + case native.EventAgentEnd: + lifecycleCause = nil + case native.EventAgentAbort: + if idleCancel.DidFire() { + lifecycleCause = context.Cause(idleCtx) + } else if context.Cause(ctx) != nil || lifecycleCause == nil { + lifecycleCause = agentAbortCause(ctx) + } + } + } + } + if hasVisibleAgentStreamOutput(event) { + hasVisibleOutput = true + } + if event.Type == native.EventAgentAbort && idleCancel.DidFire() && eventCh != nil { + if failureData, marshalErr := json.Marshal(agentFailureStreamEvent(context.Cause(idleCtx))); marshalErr == nil { + select { + case eventCh <- json.RawMessage(failureData): + failureEventForwarded = true + case <-ctx.Done(): + lifecycleCause = context.Cause(ctx) + return lifecycleCause + } + } + } + data, err := json.Marshal(publicAgentStreamEvent(event)) + if err != nil { + continue + } + if !stored && event.IsTerminal() && len(event.Messages) > 0 { + if snap, ok := extractTerminalSnapshot(data); ok { + if stepCommitter == nil { + snap.reasoningTiming = takeTerminalReasoningTiming(reasoningTiming, event.Type) + } + snap.visibleOutput = hasVisibleOutput + snap.failureCode = snapshotFailureCode(idleCancel.DidFire(), lifecycleCause) + lifecycleDeferred = lifecycleDeferred || snap.deferredToolID != "" + if snap.aborted && !lifecycleDeferred && lifecycleCause == nil { + lifecycleCause = agentAbortCause(ctx) + } + var storeErr error + if stepCommitter != nil { + storeErr = stepCommitter.finish(ctx, extractInputTokensFromUsage(snap.usage)) + } else { + storeErr = s.persistTerminalSnapshot( + context.WithoutCancel(ctx), + req, + resolvedContext{runConfig: cfg, model: models.GetResponse{ID: modelID}}, + snap, + ) + } + if storeErr != nil { + lifecycleCause = storeErr + lifecycleDeferred = false + return storeErr + } + stored = true + } + } + if eventCh != nil && shouldForwardAfterIdleFailure(event, failureEventForwarded) { + select { + case eventCh <- json.RawMessage(data): + case <-ctx.Done(): + lifecycleCause = context.Cause(ctx) + return lifecycleCause + } + } + } + if !stored && stepCommitter != nil { + if storeErr := stepCommitter.finish(ctx, 0); storeErr != nil { + lifecycleCause = storeErr + return storeErr + } + stored = true + } + if stepCommitter != nil { + if commitErr := stepCommitter.err(); commitErr != nil && ctx.Err() == nil { + lifecycleCause = commitErr + return commitErr + } + } + if idleCancel.DidFire() { + lifecycleCause = context.Cause(idleCtx) + if !stored { + if _, storeErr := s.persistTurnFailure(context.WithoutCancel(ctx), req, resolvedContext{runConfig: cfg, model: models.GetResponse{ID: modelID}}, snapshotFailureCode(true, lifecycleCause)); storeErr != nil { + s.logger.Error("decision continuation timeout persist failed", slog.Any("error", storeErr)) + } + } + if eventCh != nil && !failureEventForwarded { + if data, marshalErr := json.Marshal(agentFailureStreamEvent(lifecycleCause)); marshalErr == nil { + select { + case eventCh <- json.RawMessage(data): + case <-ctx.Done(): + } + } + } + return lifecycleCause + } + if ctx.Err() != nil { + lifecycleCause = context.Cause(ctx) + return lifecycleCause + } + if lifecycleCause == nil && !lifecycleDeferred && !terminalEventSeen { + lifecycleCause = errors.New("agent continuation ended without a terminal event") + } + return nil +} diff --git a/internal/agent/application/queue_continuation.go b/internal/agent/application/queue_continuation.go new file mode 100644 index 000000000..d87f7556f --- /dev/null +++ b/internal/agent/application/queue_continuation.go @@ -0,0 +1,263 @@ +package application + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + "github.com/felinics/memoh/internal/agent/turn" +) + +// followUpPayload is the document stored for one follow-up item. Text is the +// user-visible input and is always present so queue listings can render the +// item. Command is present when the follow-up was a complete turn that arrived +// while the session was busy; it preserves channel routing, attachments, and +// reply metadata so the continuation answers where the message came from. +type followUpPayload struct { + Text string `json:"text"` + Command *turn.StartTurnCommand `json:"command,omitempty"` +} + +func encodeFollowUpCommand(cmd turn.StartTurnCommand) ([]byte, error) { + text := strings.TrimSpace(cmd.UserVisibleText) + if text == "" { + text = strings.TrimSpace(cmd.Query) + } + return json.Marshal(followUpPayload{Text: text, Command: &cmd}) +} + +func decodeFollowUpPayload(payload []byte) followUpPayload { + var body followUpPayload + if err := json.Unmarshal(payload, &body); err != nil { + return followUpPayload{} + } + body.Text = strings.TrimSpace(body.Text) + return body +} + +// QueuePayloadText renders only user-visible text. Invalid/empty payloads never +// fall back to the raw envelope, which can contain a deferred command credential. +func QueuePayloadText(payload []byte) string { + body := decodeFollowUpPayload(payload) + if text := strings.TrimSpace(body.Text); text != "" { + return text + } + if body.Command != nil { + if text := strings.TrimSpace(body.Command.UserVisibleText); text != "" { + return text + } + return strings.TrimSpace(body.Command.Query) + } + return "" +} + +// EnqueueDeferredTurn places a complete user turn that met a busy session into +// the session's follow-up queue. The command is stored intact, so the +// continuation keeps the channel route, attachments, and reply metadata of the +// original message. The caller receives the same admission errors as any other +// follow-up: in particular ErrNoActiveRun means the run ended between the busy +// admission result and this call, and the caller should retry admission. +func (s *Service) EnqueueDeferredTurn(ctx context.Context, cmd turn.StartTurnCommand) error { + if s == nil || s.sessionManager == nil { + return errors.New("turn: deferred queue is not configured") + } + if strings.TrimSpace(cmd.BotID) == "" || strings.TrimSpace(cmd.ThreadID) == "" { + return errors.New("turn: deferred turn requires bot and thread") + } + payload, err := encodeFollowUpCommand(cmd) + if err != nil { + return err + } + invocationID := strings.TrimSpace(cmd.IdempotencyKey) + if invocationID == "" { + invocationID = uuid.NewString() + } + _, err = s.EnqueueFollowUp(ctx, cmd.BotID, cmd.ThreadID, "deferred:"+invocationID, payload) + return err +} + +// kickFollowUpIfIdle closes the admission race for follow-ups: the enqueue +// observed an active run, but that run may have reached its terminal observer +// before the item was written, in which case nobody would claim it until the +// next run of the session ends. +func (s *Service) kickFollowUpIfIdle(ctx context.Context, botID, sessionID, enqueuedDuringRunID string) { + if s == nil || s.sessionManager == nil || strings.TrimSpace(enqueuedDuringRunID) == "" { + return + } + snapshot, err := s.sessionManager.Snapshot(ctx, botID, sessionID) + if err != nil { + return + } + // Any active run, including a newer one, will claim the item at its own + // terminal boundary; only an idle session needs the kick. + if run := snapshot.CurrentRunView; run != nil && sessionruntime.IsActiveRunStatus(run.Status) { + return + } + s.startFollowUpAfterTerminal(ctx, sessionruntime.TerminalRun{ + RunID: enqueuedDuringRunID, BotID: botID, SessionID: sessionID, + }) +} + +// closeSteerQueueForRun rejects the terminal run's unapplied steers. A steer +// targets exactly one run; once that run is terminal it can never enter a +// model step, and leaving it accepted would show a dead item in the queue. +func (s *Service) closeSteerQueueForRun(ctx context.Context, terminal sessionruntime.TerminalRun) { + if s == nil || s.sessionManager == nil || terminal.RunID == "" || terminal.BotID == "" || terminal.SessionID == "" { + return + } + key := sessionruntime.Key{BotID: terminal.BotID, SessionID: terminal.SessionID} + if err := s.sessionManager.CloseSteerRun(ctx, key, terminal.RunID); err != nil && !errors.Is(err, sessionruntime.ErrLiveQueueUnavailable) && s.logger != nil { + s.logger.Warn("close steer queue for terminal run failed", + slog.String("run_id", terminal.RunID), slog.Any("error", err)) + } +} + +// startFollowUpAfterTerminal hands one transient follow-up to the ordinary +// turn admission path after a run has reached a terminal boundary. The queue +// claim is intentionally separate from run admission: admission remains the +// single owner/fencing authority, while the live queue only selects payload. +// +// Follow-ups are session-bound and start after every terminal state. An +// aborted or failed run does not invalidate input the user queued behind it; +// steers, which are run-bound, are rejected instead by closeSteerQueueForRun. +func (s *Service) startFollowUpAfterTerminal(ctx context.Context, terminal sessionruntime.TerminalRun) { + if s == nil || s.sessionManager == nil || terminal.RunID == "" || terminal.BotID == "" || terminal.SessionID == "" { + return + } + go s.startFollowUp(ctx, terminal) +} + +// followUpStart coalesces terminal/enqueue notifications while admission is in +// flight. Its lifetime ends before output is drained; output delivery must not +// hold the next run's admission gate. +type followUpStart struct { + mu sync.Mutex + closed bool + pending *sessionruntime.TerminalRun +} + +func (s *Service) startFollowUp(parent context.Context, terminal sessionruntime.TerminalRun) { + ctx := context.WithoutCancel(parent) + key := sessionruntime.Key{BotID: terminal.BotID, SessionID: terminal.SessionID} + state := &followUpStart{} + for { + current, busy := s.followUpStarts.LoadOrStore(key.String(), state) + if !busy { + break + } + active := current.(*followUpStart) + active.mu.Lock() + if active.closed { + active.mu.Unlock() + continue + } + active.pending = &terminal + active.mu.Unlock() + return + } + for { + if handle := s.admitFollowUp(ctx, key, terminal); handle != nil { + // Server-owned handles need a consumer, independently of the short + // admission loop. Terminal observers can schedule the next item. + go drainDeferredTurn(handle) + } + state.mu.Lock() + if state.pending != nil { + terminal = *state.pending + state.pending = nil + state.mu.Unlock() + continue + } + state.closed = true + s.followUpStarts.CompareAndDelete(key.String(), state) + state.mu.Unlock() + return + } +} + +func (s *Service) admitFollowUp(ctx context.Context, key sessionruntime.Key, terminal sessionruntime.TerminalRun) turn.RunHandle { + item, claim, ok, err := s.sessionManager.ClaimNextFollowUp(ctx, key, terminal.RunID) + if err != nil || !ok { + return nil + } + cmd, ok := s.followUpCommand(item) + if !ok { + _ = s.sessionManager.ReleaseFollowUp(ctx, key, claim) + return nil + } + var handle turn.RunHandle + for attempt := 0; ; attempt++ { + handle, err = s.StartTurn(ctx, cmd) + if !errors.Is(err, turn.ErrSessionBusy) || attempt >= 7 { + break + } + // ctx is detached from its parent, so only the backoff bounds the wait. + time.Sleep(time.Duration(1< 0 { + return queueStepToolLoop + } + return queueStepFinal +} diff --git a/internal/agent/application/queue_step_deferred_test.go b/internal/agent/application/queue_step_deferred_test.go new file mode 100644 index 000000000..543bc7d63 --- /dev/null +++ b/internal/agent/application/queue_step_deferred_test.go @@ -0,0 +1,126 @@ +package application + +import ( + "context" + "errors" + "testing" + + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + messagepkg "github.com/felinics/memoh/internal/chat/message" + dbstore "github.com/felinics/memoh/internal/db/store" + "github.com/felinics/memoh/internal/testutil/sessionledger" +) + +type nilQueries struct{ dbstore.Queries } + +type queueTestFence struct{} + +func (queueTestFence) Activate(context.Context, string, string, int64) error { return nil } + +// newDeferredSteerTestService builds a Service whose queue step transaction +// can run without PostgreSQL: steps carry no messages, so history persistence +// is skipped, and queue state lives in a memory backend with one active run. +func newDeferredSteerTestService(t *testing.T, backends ...sessionruntime.Backend) (*Service, sessionruntime.RunHandle) { + t.Helper() + var backend sessionruntime.Backend = sessionruntime.NewMemoryBackend() + if len(backends) > 0 { + backend = backends[0] + } + manager := sessionruntime.NewManager(backend, sessionruntime.Options{OwnerID: "owner-1", Ledger: sessionledger.New(), Fence: queueTestFence{}}) + t.Cleanup(func() { _ = manager.Close() }) + admitted, err := manager.Admit(context.Background(), sessionruntime.AdmitInput{ + BotID: "bot", SessionID: "session", InvocationID: "initial", Payload: []byte(`{}`), + Execution: sessionruntime.Execution{Admission: func(context.Context, sessionruntime.RunHandle) (sessionruntime.RunAdmissionView, error) { + return sessionruntime.RunAdmissionView{}, nil + }}, + }) + if err != nil { + t.Fatal(err) + } + handle := admitted.Handle + if err := manager.EnableSteer(context.Background(), handle); err != nil { + t.Fatal(err) + } + service := &Service{ + sessionManager: manager, + messageService: &recordingStepPersister{recordingMessageService: &recordingMessageService{}}, + queries: nilQueries{}, + } + return service, handle +} + +// A deferred step parks the loop, so its commit must not claim a steer: the +// inject channel is never read again and a claim would sit unapplied across +// the decision and across any owner change. The continuation's first committed +// step claims and injects it instead, and the next step applies it once. +func TestDeferredStepDoesNotClaimSteerAndContinuationDeliversIt(t *testing.T) { + service, handle := newDeferredSteerTestService(t) + ctx := context.Background() + key := sessionruntime.Key{BotID: handle.BotID, SessionID: handle.SessionID} + item, err := service.EnqueueSteer(ctx, handle.BotID, handle.SessionID, "invoke-1", []byte(`{"text":"steer me"}`)) + if err != nil { + t.Fatal(err) + } + + // Original run: the deferred step commits without touching the queue. + original := newQueueStepCoordinator(service, ChatRequest{ + BotID: handle.BotID, ThreadID: handle.SessionID, RunID: handle.RunID, + RunHandle: handle, QueueSteerEnabled: true, + }) + if original == nil { + t.Fatal("queue step transaction unavailable") + } + outcome, err := original.commit(ctx, queueStepDeferredDecision, messagepkg.AgentStep{RunID: handle.RunID}, nil) + if err != nil { + t.Fatalf("deferred commit: %v", err) + } + if outcome.claimedSteer != nil || outcome.appliedSteerItemID != "" { + t.Fatalf("deferred step touched the steer queue: %#v", outcome) + } + + if steers, _, err := service.sessionManager.PendingQueues(ctx, key, 0); err != nil || len(steers) != 1 || steers[0].Status != sessionruntime.QueueAccepted { + t.Fatalf("steer should stay accepted across the park: %#v, %v", steers, err) + } + + // Continuation after the decision uses a fresh coordinator with the same run. + continuation := newQueueStepCoordinator(service, ChatRequest{ + BotID: handle.BotID, ThreadID: handle.SessionID, RunID: handle.RunID, + RunHandle: handle, QueueSteerEnabled: true, UserMessagePersisted: true, + }) + if continuation == nil { + t.Fatal("continuation queue step transaction unavailable") + } + + // Step N+1: the model call that consumed the approved tool result. Its + // commit claims the steer for step N+2. + outcome, err = continuation.commit(ctx, queueStepToolLoop, messagepkg.AgentStep{RunID: handle.RunID}, nil) + if err != nil { + t.Fatalf("continuation commit: %v", err) + } + if outcome.appliedSteerItemID != "" { + t.Fatalf("continuation applied a steer it never injected: %#v", outcome) + } + if outcome.claimedSteer == nil || outcome.claimedSteer.ID != item.ID { + t.Fatalf("continuation did not claim the steer: %#v", outcome) + } + + steers, _, err := service.sessionManager.PendingQueues(ctx, key, 0) + if err != nil || len(steers) != 0 { + t.Fatalf("pending steers while claimed = %#v, %v", steers, err) + } + + // Step N+2 saw the steer; its commit applies the claim exactly once. + outcome, err = continuation.commit(ctx, queueStepFinal, messagepkg.AgentStep{RunID: handle.RunID}, nil) + if err != nil { + t.Fatalf("final commit: %v", err) + } + if outcome.appliedSteerItemID != string(item.ID) || outcome.claimedSteer != nil || outcome.continueAfterFinal { + t.Fatalf("final outcome = %#v", outcome) + } + if _, err := service.sessionManager.UpdateSteer(ctx, key, item.ID, []byte("x")); err == nil { + t.Fatal("applied steer still mutable") + } + if _, err := service.EnqueueSteer(ctx, handle.BotID, handle.SessionID, "invoke-2", []byte(`{"text":"late"}`)); !errors.Is(err, sessionruntime.ErrQueueNoActiveRun) { + t.Fatalf("late steer after sealed final = %v", err) + } +} diff --git a/internal/agent/application/queue_step_failure_test.go b/internal/agent/application/queue_step_failure_test.go new file mode 100644 index 000000000..c848aa1d4 --- /dev/null +++ b/internal/agent/application/queue_step_failure_test.go @@ -0,0 +1,85 @@ +package application + +import ( + "context" + "errors" + "testing" + + sdk "github.com/felinics/twilight/sdk" + + contextfrag "github.com/felinics/memoh/internal/agent/context/fragment" + "github.com/felinics/memoh/internal/agent/runtime/native" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + messagepkg "github.com/felinics/memoh/internal/chat/message" + "github.com/felinics/memoh/internal/runtimefence" +) + +type failedSteerApplyBackend struct { + *sessionruntime.MemoryBackend + applyErr error + releases int +} + +func (b *failedSteerApplyBackend) ApplySteer(ctx context.Context, key sessionruntime.Key, ref sessionruntime.SteerClaimRef) error { + if b.applyErr != nil { + return b.applyErr + } + return b.MemoryBackend.ApplySteer(ctx, key, ref) +} + +func (b *failedSteerApplyBackend) ReleaseSteer(ctx context.Context, key sessionruntime.Key, ref sessionruntime.SteerClaimRef) error { + b.releases++ + return b.MemoryBackend.ReleaseSteer(ctx, key, ref) +} + +func TestStepCommitSeparatesHistoryFailureFromQueueFailure(t *testing.T) { + for _, historyFails := range []bool{false, true} { + t.Run(map[bool]string{false: "queue apply after committed history", true: "history before queue apply"}[historyFails], func(t *testing.T) { + failure := errors.New("injected boundary failure") + backend := &failedSteerApplyBackend{MemoryBackend: sessionruntime.NewMemoryBackend()} + service, handle := newDeferredSteerTestService(t, backend) + ctx := runtimefence.WithContext(context.Background(), runtimefence.Fence{BotID: handle.BotID, SessionID: handle.SessionID, Token: handle.FencingToken}) + item, err := service.EnqueueSteer(ctx, handle.BotID, handle.SessionID, "steer-failure", []byte(`{"text":"adjust"}`)) + if err != nil { + t.Fatal(err) + } + req := ChatRequest{BotID: handle.BotID, ThreadID: handle.SessionID, RunID: handle.RunID, RunHandle: handle, UserMessagePersisted: true, PersistedUserMessageID: "user", QueueSteerEnabled: true} + committer := service.newAgentStepCommitter(ctx, req, resolvedContext{runConfig: native.RunConfig{ContextLifecycle: contextfrag.NewLifecycleHolder()}}) + if committer == nil { + t.Fatal("missing fenced step committer") + } + if _, err := committer.queueStep.commit(ctx, queueStepToolLoop, messagepkg.AgentStep{}, nil); err != nil { + t.Fatal(err) + } + store := service.messageService.(*recordingStepPersister) + if historyFails { + store.stepErr = failure + } else { + backend.applyErr = failure + } + step := &sdk.StepResult{FinishReason: sdk.FinishReasonStop, Messages: []sdk.Message{sdk.AssistantMessage("committed response")}} + if err := committer.commit(ctx, 0, step); !errors.Is(err, failure) { + t.Fatalf("commit error=%v", err) + } + pending, _, err := service.sessionManager.PendingQueues(ctx, sessionruntime.Key{BotID: handle.BotID, SessionID: handle.SessionID}, 0) + if err != nil { + t.Fatal(err) + } + if historyFails { + if len(committer.persistedMessages()) != 0 || committer.nextStep != 0 || backend.releases != 1 || len(pending) != 1 || pending[0].ID != item.ID { + t.Fatal("uncommitted step did not return its claim") + } + } else { + if len(committer.persistedMessages()) != 1 || committer.nextStep != 1 || backend.releases != 0 || len(pending) != 0 { + t.Fatal("committed prefix was lost or its claim was released") + } + if err := committer.commit(ctx, 0, step); err == nil { + t.Fatal("already-persisted step was accepted twice") + } + if len(store.steps) != 1 { + t.Fatalf("history writes=%d", len(store.steps)) + } + } + }) + } +} diff --git a/internal/agent/application/runtime_decision.go b/internal/agent/application/runtime_decision.go index 147c9662f..104af0f79 100644 --- a/internal/agent/application/runtime_decision.go +++ b/internal/agent/application/runtime_decision.go @@ -245,7 +245,7 @@ func (s *Service) handleRuntimeDecisionCommand(ctx context.Context, command sess if s == nil || s.decisionRuntime == nil { return errors.New("runtime decision handler is not configured") } - runCtx, runCancel, err := s.decisionRuntime.DecisionContinuationContext(command) + runCtx, runCancel, runHandle, err := s.decisionRuntime.DecisionContinuationContext(command) if err != nil { return err } @@ -273,6 +273,7 @@ func (s *Service) handleRuntimeDecisionCommand(ctx context.Context, command sess return err } committed.runID = command.RunID + committed.runHandle = runHandle s.publishCommittedRuntimeDecision(runCtx, command, native.StreamEvent{ Type: native.EventUserInputRequest, ToolName: committed.request.ToolName, @@ -317,6 +318,7 @@ func (s *Service) handleRuntimeDecisionCommand(ctx context.Context, command sess return err } committed.runID = command.RunID + committed.runHandle = runHandle s.publishCommittedRuntimeDecision(runCtx, command, native.StreamEvent{ Type: native.EventToolApprovalRequest, ToolName: committed.request.ToolName, @@ -399,6 +401,7 @@ func (s *Service) continueRuntimeDecision( if err := s.decisionRuntime.WaitDecisionContinuationReady(ctx, command); err != nil { outputCause = err + s.logRuntimeDecisionContinuationFailure(command, err) s.recoverContextLifecycleFromAssistantMetadata(ctx, command.RunID, command.BotID, command.SessionID, err) s.finishRuntimeDecision(ctx, handle, err) return @@ -465,6 +468,7 @@ func (s *Service) continueRuntimeDecision( } if runErr != nil { outputCause = runErr + s.logRuntimeDecisionContinuationFailure(command, lifecycleCause) s.persistRuntimeDecisionLifecycle(ctx, command, lifecycle, lifecycleCause) s.finishRuntimeDecision(ctx, handle, runErr) return @@ -474,9 +478,47 @@ func (s *Service) continueRuntimeDecision( return } s.persistRuntimeDecisionLifecycle(ctx, command, lifecycle, lifecycleCause) + s.logRuntimeDecisionContinuationFailure(command, lifecycleCause) s.finishRuntimeDecision(ctx, handle, lifecycleCause) } +// logRuntimeDecisionContinuationFailure records the private provider, +// persistence, or ownership cause after a durably answered decision resumes a +// run. The websocket and session ledger deliberately retain only the stable +// public error code; without this log an operator cannot distinguish those +// failure classes from the generic agent.response_interrupted response. +func (s *Service) logRuntimeDecisionContinuationFailure(command sessionruntime.Command, cause error) { + if s == nil || s.logger == nil || cause == nil { + return + } + privateCause := apperror.CauseOf(cause) + if privateCause == nil { + privateCause = cause + } + s.logger.Error("runtime decision continuation failed", + slog.Any("error", privateCause), + slog.String("run_id", command.RunID), + slog.String("decision_id", command.TargetID), + slog.String("command_type", command.Type), + ) +} + +// logContinuationStreamError records the private detail of a native error +// event observed while a decision continuation streams. publicAgentStreamEvent +// replaces that detail with a stable code before the event leaves the +// application, so this is the only place the original text is retained. +func (s *Service) logContinuationStreamError(runID string, event native.StreamEvent) { + if s == nil || s.logger == nil { + return + } + s.logger.Error("decision continuation stream error", + slog.String("run_id", strings.TrimSpace(runID)), + slog.String("event_type", string(event.Type)), + slog.String("code", strings.TrimSpace(event.Code)), + slog.String("error", strings.TrimSpace(event.Error)), + ) +} + func firstLifecycleCause(causes ...error) error { for _, cause := range causes { if cause != nil { diff --git a/internal/agent/application/runtime_decision_continuation_lifecycle_test.go b/internal/agent/application/runtime_decision_continuation_lifecycle_test.go index 3d431e262..55a8cfead 100644 --- a/internal/agent/application/runtime_decision_continuation_lifecycle_test.go +++ b/internal/agent/application/runtime_decision_continuation_lifecycle_test.go @@ -77,6 +77,7 @@ func TestRuntimeDecisionContinuationsPropagateResolvedBudgetAndAdmittedRunIDToAg }, UserInputResponseInput{BotID: lifecycleTestBotID, ThreadID: lifecycleTestSessionID}, lifecycleTestRunID, + sessionruntime.RunHandle{}, &continuationLifecycleResult{}, nil, ) @@ -93,6 +94,7 @@ func TestRuntimeDecisionContinuationsPropagateResolvedBudgetAndAdmittedRunIDToAg }, ToolApprovalResponseInput{BotID: lifecycleTestBotID, ThreadID: lifecycleTestSessionID}, lifecycleTestRunID, + sessionruntime.RunHandle{}, &continuationLifecycleResult{}, nil, ) @@ -142,6 +144,7 @@ func TestRuntimeOwnedDecisionContinuationsRetainLifecycleWithoutAssistantMetadat }, UserInputResponseInput{BotID: lifecycleTestBotID, ThreadID: lifecycleTestSessionID}, lifecycleTestRunID, + sessionruntime.RunHandle{}, lifecycle, nil, ) @@ -158,6 +161,7 @@ func TestRuntimeOwnedDecisionContinuationsRetainLifecycleWithoutAssistantMetadat }, ToolApprovalResponseInput{BotID: lifecycleTestBotID, ThreadID: lifecycleTestSessionID}, lifecycleTestRunID, + sessionruntime.RunHandle{}, lifecycle, nil, ) diff --git a/internal/agent/application/runtime_decision_finish_test.go b/internal/agent/application/runtime_decision_finish_test.go index 973d34953..ce17f7ee2 100644 --- a/internal/agent/application/runtime_decision_finish_test.go +++ b/internal/agent/application/runtime_decision_finish_test.go @@ -17,6 +17,7 @@ import ( "github.com/felinics/memoh/internal/db/postgres/sqlc" dbstore "github.com/felinics/memoh/internal/db/store" "github.com/felinics/memoh/internal/runtimefence" + sessiontest "github.com/felinics/memoh/internal/testutil/sessionruntime" ) type finishDecisionQueries struct { @@ -89,7 +90,7 @@ func TestWebAndChannelStopCloseParkedInputWithoutContinuing(t *testing.T) { t.Fatalf("cleanup replay: %v, calls=%d", err, input.cancelCalls) } // The runtime must release the slot as well as the decision row. - if _, err := manager.StartRunHandle(context.Background(), handle.BotID, handle.SessionID, + if _, err := sessiontest.Start(context.Background(), manager, handle.BotID, handle.SessionID, "55555555-5555-4555-8555-555555555555", make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)); err != nil { t.Fatalf("next run blocked: %v", err) } diff --git a/internal/agent/application/runtime_decision_test.go b/internal/agent/application/runtime_decision_test.go index 3d70299b6..5ecb41a7d 100644 --- a/internal/agent/application/runtime_decision_test.go +++ b/internal/agent/application/runtime_decision_test.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "errors" + "log/slog" + "strings" "sync/atomic" "testing" "time" @@ -13,15 +15,38 @@ import ( sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/agent/turn" "github.com/felinics/memoh/internal/apperror" + sessiontest "github.com/felinics/memoh/internal/testutil/sessionruntime" ) +func TestRuntimeDecisionContinuationLogsPrivateCause(t *testing.T) { + var logs bytes.Buffer + service := &Service{logger: slog.New(slog.NewTextHandler(&logs, nil))} + privateCause := errors.New("private provider rejection") + service.logRuntimeDecisionContinuationFailure(sessionruntime.Command{ + RunID: "run-log", TargetID: "decision-log", Type: sessionruntime.CommandUserInputResponse, + }, apperror.Wrap(apperror.CodeAgentResponseInterrupted, privateCause, nil)) + + got := logs.String() + for _, want := range []string{ + "runtime decision continuation failed", + "private provider rejection", + "run_id=run-log", + "decision_id=decision-log", + "command_type=user_input_response", + } { + if !strings.Contains(got, want) { + t.Fatalf("continuation failure log %q does not contain %q", got, want) + } + } +} + func newWaitingDecisionRuntime(t *testing.T, backends ...sessionruntime.Backend) (*sessionruntime.Manager, sessionruntime.RunHandle) { t.Helper() var backend sessionruntime.Backend = sessionruntime.NewMemoryBackend() if len(backends) > 0 { backend = backends[0] } - manager := sessionruntime.NewManager(backend, sessionruntime.Options{ + manager := sessiontest.New(backend, sessionruntime.Options{ OwnerID: "runtime-lifecycle-owner", StateTTL: time.Minute, OwnerLeaseTTL: time.Second, @@ -31,8 +56,7 @@ func newWaitingDecisionRuntime(t *testing.T, backends ...sessionruntime.Backend) if err := manager.Start(context.Background()); err != nil { t.Fatalf("start runtime manager: %v", err) } - handle, err := manager.StartRunHandle( - context.Background(), + handle, err := sessiontest.Start(context.Background(), manager, lifecycleTestBotID, lifecycleTestSessionID, lifecycleTestRunID, @@ -66,7 +90,7 @@ func runtimeDecisionEvent(t *testing.T, event native.StreamEvent) WSStreamEvent } type failNextRuntimeDecisionBackend struct { - sessionruntime.Backend + *sessionruntime.MemoryBackend failNext atomic.Bool err error } @@ -79,7 +103,7 @@ func (b *failNextRuntimeDecisionBackend) Update( if b.failNext.CompareAndSwap(true, false) { return sessionruntime.Snapshot{}, false, b.err } - return b.Backend.Update(ctx, key, update) + return b.MemoryBackend.Update(ctx, key, update) } func TestRuntimeDecisionTerminalDoesNotExposePrivateErrors(t *testing.T) { @@ -141,7 +165,7 @@ func TestContinueRuntimeDecisionDoesNotParkProviderCancellation(t *testing.T) { sessionID = "session-provider-cancel" runID = "run-provider-cancel" ) - manager := sessionruntime.NewManager(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ + manager := sessiontest.New(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ OwnerID: "owner-provider-cancel", StateTTL: time.Minute, OwnerLeaseTTL: time.Second, @@ -151,8 +175,7 @@ func TestContinueRuntimeDecisionDoesNotParkProviderCancellation(t *testing.T) { if err := manager.Start(context.Background()); err != nil { t.Fatalf("start runtime manager: %v", err) } - handle, err := manager.StartRunHandle( - context.Background(), + handle, err := sessiontest.Start(context.Background(), manager, botID, sessionID, runID, @@ -201,10 +224,10 @@ func TestContinueRuntimeDecisionCancelsContinuationAfterPublicationFailure(t *te ) publishErr := errors.New("private runtime publication failure") backend := &failNextRuntimeDecisionBackend{ - Backend: sessionruntime.NewMemoryBackend(), - err: publishErr, + MemoryBackend: sessionruntime.NewMemoryBackend(), + err: publishErr, } - manager := sessionruntime.NewManager(backend, sessionruntime.Options{ + manager := sessiontest.New(backend, sessionruntime.Options{ OwnerID: "owner-publish-failure", StateTTL: time.Minute, OwnerLeaseTTL: time.Second, @@ -214,8 +237,7 @@ func TestContinueRuntimeDecisionCancelsContinuationAfterPublicationFailure(t *te if err := manager.Start(context.Background()); err != nil { t.Fatalf("start runtime manager: %v", err) } - handle, err := manager.StartRunHandle( - context.Background(), + handle, err := sessiontest.Start(context.Background(), manager, botID, sessionID, runID, diff --git a/internal/agent/application/service.go b/internal/agent/application/service.go index 3b2ff77e7..a4d656d0f 100644 --- a/internal/agent/application/service.go +++ b/internal/agent/application/service.go @@ -156,6 +156,9 @@ type Service struct { contextLifecycleCandidates map[contextLifecycleCandidateKey]contextLifecycleCandidate publishTurnEvent func(context.Context, sessionruntime.RunHandle, native.StreamEvent) error turnHooks *turnRuntimeHooks + sessionManager *sessionruntime.Manager + // followUpStarts holds one in-flight follow-up starter per session key. + followUpStarts sync.Map } // NewService creates an application service backed by the native agent. @@ -209,7 +212,7 @@ func NewService( Timeout: 10 * time.Minute, } - return &Service{ + service := &Service{ agent: a, modelsService: modelsService, queries: queries, @@ -225,6 +228,7 @@ func NewService( clockLocation: clockLocation, logger: log.With(slog.String("service", "agent/application")), } + return service } // SetContextAbsoluteMaxTokens sets the server-wide context admission cap @@ -822,9 +826,11 @@ func (s *Service) Chat(ctx context.Context, req ChatRequest) (ChatResponse, erro go s.maybeGenerateSessionTitle(context.WithoutCancel(ctx), req, req.RawQuery) cfg := rc.runConfig + cfg.StepIndexOffset = req.StepIndexOffset stepCommitter := s.newAgentStepCommitter(ctx, req, rc) if stepCommitter != nil { cfg.OnStepCommitted = stepCommitter.commit + stepCommitter.bindContinuation(&cfg) } cfg = s.prepareRunConfig(ctx, cfg) terminal := s.contextLifecycleTerminal(ctx, cfg) diff --git a/internal/agent/application/service_retry_edit.go b/internal/agent/application/service_retry_edit.go index 53185202f..08c8be802 100644 --- a/internal/agent/application/service_retry_edit.go +++ b/internal/agent/application/service_retry_edit.go @@ -8,6 +8,7 @@ import ( "log/slog" "strings" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" turnpkg "github.com/felinics/memoh/internal/agent/turn" "github.com/felinics/memoh/internal/apperror" messageevent "github.com/felinics/memoh/internal/chat/event" @@ -30,6 +31,10 @@ type RetryLatestMessageInput struct { ReasoningEffort string WorkspaceTargetID string ToolHTTPURL string + // RunHandle and InjectCh are server-owned admission capabilities. They are + // populated only by the in-process Web runtime, never by client JSON. + RunHandle sessionruntime.RunHandle + InjectCh chan turnpkg.InjectMessage // OnModelPreferenceSettled releases subsequent picker writes once this // turn's preference write-back has finished (issue #879). Same contract // as ChatRequest.OnModelPreferenceSettled. @@ -53,6 +58,8 @@ type EditLatestMessageInput struct { ReasoningEffort string WorkspaceTargetID string ToolHTTPURL string + RunHandle sessionruntime.RunHandle + InjectCh chan turnpkg.InjectMessage // OnModelPreferenceSettled: see RetryLatestMessageInput. OnModelPreferenceSettled func() } @@ -77,6 +84,7 @@ func (s *Service) RetryLatestMessageWS(ctx context.Context, input RetryLatestMes ChatID: strings.TrimSpace(input.BotID), ThreadID: sessionID, RunID: strings.TrimSpace(input.RunID), + RunHandle: input.RunHandle, TurnID: strings.TrimSpace(input.TurnID), TurnPosition: input.TurnPosition, UserID: strings.TrimSpace(input.ActorUserID), @@ -93,6 +101,8 @@ func (s *Service) RetryLatestMessageWS(ctx context.Context, input RetryLatestMes ReasoningEffort: strings.TrimSpace(input.ReasoningEffort), WorkspaceTargetID: strings.TrimSpace(input.WorkspaceTargetID), ToolHTTPURL: strings.TrimSpace(input.ToolHTTPURL), + InjectCh: input.InjectCh, + QueueSteerEnabled: input.InjectCh != nil, ReusePersistedUserMessage: true, PersistedUserMessageID: requestMessage.ID, SkipHistoryTurn: true, @@ -120,6 +130,7 @@ func (s *Service) EditLatestMessageWS(ctx context.Context, input EditLatestMessa ChatID: strings.TrimSpace(input.BotID), ThreadID: sessionID, RunID: strings.TrimSpace(input.RunID), + RunHandle: input.RunHandle, TurnID: strings.TrimSpace(input.TurnID), TurnPosition: input.TurnPosition, UserID: strings.TrimSpace(input.ActorUserID), @@ -137,6 +148,8 @@ func (s *Service) EditLatestMessageWS(ctx context.Context, input EditLatestMessa ReasoningEffort: strings.TrimSpace(input.ReasoningEffort), WorkspaceTargetID: strings.TrimSpace(input.WorkspaceTargetID), ToolHTTPURL: strings.TrimSpace(input.ToolHTTPURL), + InjectCh: input.InjectCh, + QueueSteerEnabled: input.InjectCh != nil, SkipHistoryTurn: true, HistoryCutoffBeforeMessageID: strings.TrimSpace(turn.RequestMessageID), OnModelPreferenceSettled: input.OnModelPreferenceSettled, @@ -273,6 +286,17 @@ func (s *Service) streamReplacementWS( eventCh chan<- WSStreamEvent, abortCh <-chan struct{}, ) error { + replacement := &messagepkg.TurnReplacement{ + OldTurnID: strings.TrimSpace(oldTurnID), + ReplacementTurnID: strings.TrimSpace(req.TurnID), + ReplacementTurnPosition: req.TurnPosition, + RequestMessageID: strings.TrimSpace(requestMessageID), + Reason: strings.TrimSpace(reason), + } + if update := s.prepareForkAnchorUpdate(ctx, req.ThreadID, req.HistoryCutoffBeforeMessageID); update != nil { + replacement.SessionMetadata = update.metadata + } + req.TurnReplacement = replacement _, err := s.streamChatWSResultWithHooks( ctx, req, diff --git a/internal/agent/application/service_run_lifecycle_test.go b/internal/agent/application/service_run_lifecycle_test.go index db35a04e4..a4ea40e01 100644 --- a/internal/agent/application/service_run_lifecycle_test.go +++ b/internal/agent/application/service_run_lifecycle_test.go @@ -84,15 +84,6 @@ func (a *lifecycleTurnAdmitter) Admit( return a.admission, nil } -func (a *lifecycleTurnAdmitter) FinishRun( - _ context.Context, - handle sessionruntime.RunHandle, - status, message string, -) error { - a.finishes = append(a.finishes, recordedFinish{handle: handle, status: status, message: message}) - return a.finishErr -} - func (a *lifecycleTurnAdmitter) FinishRunWithErrorCode( _ context.Context, handle sessionruntime.RunHandle, diff --git a/internal/agent/application/service_stream.go b/internal/agent/application/service_stream.go index 697376047..e944337bc 100644 --- a/internal/agent/application/service_stream.go +++ b/internal/agent/application/service_stream.go @@ -11,7 +11,6 @@ import ( sdk "github.com/felinics/twilight/sdk" "github.com/felinics/memoh/internal/agent/runtime/native" - sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/apperror" messagepkg "github.com/felinics/memoh/internal/chat/message" ) @@ -231,11 +230,15 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea go s.maybeGenerateSessionTitle(context.WithoutCancel(streamCtx), streamReq, streamReq.RawQuery) cfg := rc.runConfig + cfg.StepIndexOffset = streamReq.StepIndexOffset cfg.LiveToolStream = true cfg.CanRequestUserInput = s.canDeliverUserInputStream() reasoningTiming := newReasoningTimingTracker(nil) stepCommitter := s.newAgentStepCommitter(streamCtx, streamReq, rc) configureNativeReasoningTiming(&cfg, reasoningTiming, stepCommitter) + if stepCommitter != nil { + stepCommitter.bindContinuation(&cfg) + } cfg = s.prepareRunConfig(streamCtx, cfg) terminal := s.contextLifecycleTerminal(streamCtx, cfg) var lifecycleCause error @@ -260,6 +263,7 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea var terminalEventSeen bool var agentStreamErr error var failureEventForwarded bool + var deferredRuntimeTerminal *native.StreamEvent for event := range eventCh { idleCancel.Reset() // each event resets the idle timer @@ -321,6 +325,19 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea continue } var terminalPersistErr error + // A live queue step must commit history before its terminal runtime event + // marks the live projection completed. Otherwise CommitStep cannot + // publish the claimed steer or create the follow-up continuation: the + // manager quite correctly rejects a queue mutation against a terminal + // run. Non-terminal events retain their low-latency publication path. + if streamReq.PublishRuntimeEvents && s.publishTurnEvent != nil { + if event.IsTerminal() && stepCommitter != nil { + terminal := event + deferredRuntimeTerminal = &terminal + } else if publishErr := s.publishTurnEvent(streamCtx, streamReq.RunHandle, event); publishErr != nil { + s.logger.Warn("continuation runtime event publish failed", slog.String("run_id", streamReq.RunID), slog.Any("error", publishErr)) + } + } if event.IsTerminal() && len(event.Messages) > 0 { if snap, ok := extractTerminalSnapshot(data); ok { if stepCommitter == nil { @@ -363,9 +380,6 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea if event.IsTerminal() && !stored && !runOwnershipLost(streamCtx) && terminalPersistErr == nil { switch { case !hasVisibleOutput: - // A terminal event before any visible assistant output has no - // output row to persist. The admitted user message is already - // durable for both clean completion and cancellation. stored = true case stepCommitter != nil: if storeErr := stepCommitter.finish(streamCtx, rc.estimatedTokens); storeErr != nil { @@ -381,12 +395,10 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea } } if event.IsTerminal() && (terminalPersistErr != nil || runOwnershipLost(streamCtx)) { - // A terminal runtime proposal is recoverable only after the output - // it names is durable. Withhold the terminal event on persistence - // failure; the error channel finishes the run as failed instead. if terminalPersistErr != nil && agentStreamErr == nil { agentStreamErr = terminalPersistErr } + deferredRuntimeTerminal = nil continue } @@ -446,6 +458,11 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea } } } + if deferredRuntimeTerminal != nil && streamReq.PublishRuntimeEvents && s.publishTurnEvent != nil { + if publishErr := s.publishTurnEvent(context.WithoutCancel(streamCtx), streamReq.RunHandle, *deferredRuntimeTerminal); publishErr != nil { + s.logger.Warn("continuation terminal runtime event publish failed", slog.String("run_id", streamReq.RunID), slog.Any("error", publishErr)) + } + } if commitErr := stepCommitter.err(); commitErr != nil && streamCtx.Err() == nil { if lifecycleCause == nil { lifecycleCause = commitErr @@ -584,11 +601,15 @@ func (s *Service) streamChatWSResultWithHooks( }() cfg := rc.runConfig + cfg.StepIndexOffset = req.StepIndexOffset cfg.LiveToolStream = true cfg.CanRequestUserInput = s.canDeliverUserInputWS(eventCh) reasoningTiming := newReasoningTimingTracker(nil) stepCommitter := s.newAgentStepCommitter(streamCtx, req, rc) configureNativeReasoningTiming(&cfg, reasoningTiming, stepCommitter) + if stepCommitter != nil { + stepCommitter.bindContinuation(&cfg) + } cfg = s.prepareRunConfig(streamCtx, cfg) terminal := s.contextLifecycleTerminal(streamCtx, cfg) var lifecycleCause error @@ -671,7 +692,6 @@ func (s *Service) streamChatWSResultWithHooks( continue } - var terminalPersistErr error if event.IsTerminal() && len(event.Messages) > 0 { if snap, ok := extractTerminalSnapshot(data); ok { if stepCommitter == nil { @@ -687,9 +707,8 @@ func (s *Service) streamChatWSResultWithHooks( } if !stored && !runOwnershipLost(ctx) && stepCommitter != nil { if storeErr := stepCommitter.finish(ctx, extractInputTokensFromUsage(snap.usage)); storeErr != nil { - terminalPersistErr = runtimeHistoryError(storeErr) if lifecycleCause == nil { - lifecycleCause = terminalPersistErr + lifecycleCause = storeErr } s.logger.Error("ws step finalization failed", slog.Any("error", storeErr)) } else { @@ -699,9 +718,8 @@ func (s *Service) streamChatWSResultWithHooks( } else if !stored && !runOwnershipLost(ctx) { persisted, storeErr := s.persistTerminalSnapshotResult(context.WithoutCancel(ctx), req, rc, snap) if storeErr != nil { - terminalPersistErr = runtimeHistoryError(storeErr) if lifecycleCause == nil { - lifecycleCause = terminalPersistErr + lifecycleCause = storeErr } s.logger.Error("ws persist failed", slog.Any("error", storeErr)) } else { @@ -711,39 +729,8 @@ func (s *Service) streamChatWSResultWithHooks( } } } - if event.IsTerminal() && !stored && !runOwnershipLost(ctx) && terminalPersistErr == nil { - switch { - case !hasVisibleOutput: - // A terminal event before any visible assistant output has no - // output row to persist. The admitted user message is already - // durable for both clean completion and cancellation. - stored = true - case stepCommitter != nil: - if storeErr := stepCommitter.finish(ctx, rc.estimatedTokens); storeErr != nil { - terminalPersistErr = runtimeHistoryError(storeErr) - } else { - persistedMessages = stepCommitter.persistedMessages() - stored = true - } - default: - terminalPersistErr = runtimeHistoryError(errors.New("agent terminal event has no persistable snapshot")) - } - if terminalPersistErr != nil && lifecycleCause == nil { - lifecycleCause = terminalPersistErr - } - } - if event.IsTerminal() && terminalPersistErr != nil { - // postPersist and terminal publication both require the canonical - // history write. Returning here keeps the runtime recoverable instead - // of proposing completion for output that never committed. - lifecycleDeferred = false - return persistedMessages, terminalPersistErr - } - if event.IsTerminal() && runOwnershipLost(ctx) { - return persistedMessages, sessionruntime.ErrRunOwnershipLost - } - if event.IsTerminal() && postPersist != nil && !postPersistApplied { + if event.IsTerminal() && postPersist != nil && stepCommitter == nil && !postPersistApplied { if err := postPersist(context.WithoutCancel(ctx), persistedMessages); err != nil { lifecycleCause = err lifecycleDeferred = false @@ -825,7 +812,7 @@ func (s *Service) streamChatWSResultWithHooks( } } - if postPersist != nil && !postPersistApplied { + if postPersist != nil && stepCommitter == nil && !postPersistApplied { if err := postPersist(context.WithoutCancel(ctx), persistedMessages); err != nil { lifecycleCause = err lifecycleDeferred = false diff --git a/internal/agent/application/service_tool_approval.go b/internal/agent/application/service_tool_approval.go index e06a3d60a..b3cfae7fa 100644 --- a/internal/agent/application/service_tool_approval.go +++ b/internal/agent/application/service_tool_approval.go @@ -2,10 +2,8 @@ package application import ( "context" - "encoding/json" "errors" "fmt" - "log/slog" "strings" sdk "github.com/felinics/twilight/sdk" @@ -13,9 +11,9 @@ import ( contextlimit "github.com/felinics/memoh/internal/agent/context/limit" toolapproval "github.com/felinics/memoh/internal/agent/decision/approval" "github.com/felinics/memoh/internal/agent/runtime/native" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/bots" sessionpkg "github.com/felinics/memoh/internal/chat/thread" - "github.com/felinics/memoh/internal/models" "github.com/felinics/memoh/internal/workspace" ) @@ -41,6 +39,7 @@ type CommittedToolApprovalResponse struct { request toolapproval.Request input ToolApprovalResponseInput runID string + runHandle sessionruntime.RunHandle isExternalAgent bool activePrompt *externalAgentActivePromptSubscription ackOnly bool @@ -240,7 +239,7 @@ func (s *Service) continueCommittedToolApprovalResponse( default: return fmt.Errorf("committed tool approval has unexpected status %q", target.Status) } - return s.storeToolResultAndContinue(ctx, target, committed.input, toolResult, runID, lifecycle, eventCh) + return s.storeToolResultAndContinue(ctx, target, committed.input, toolResult, runID, committed.runHandle, lifecycle, eventCh) } func (s *Service) toolOutputLimit() contextlimit.ToolOutputLimit { @@ -395,6 +394,7 @@ func (s *Service) storeToolResultAndContinue( input ToolApprovalResponseInput, result sdk.ToolResultPart, runID string, + runHandle sessionruntime.RunHandle, lifecycle *continuationLifecycleResult, eventCh chan<- WSStreamEvent, ) error { @@ -421,7 +421,7 @@ func (s *Service) storeToolResultAndContinue( if err := s.storeRoundWithOptions(ctx, storeReq, modelMessages, "", storeRoundOptions{AllowPendingToolCalls: true}); err != nil { return err } - return s.continueToolApprovalSession(ctx, approval, input, runID, lifecycle, eventCh) + return s.continueToolApprovalSession(ctx, approval, input, runID, runHandle, lifecycle, eventCh) } func (s *Service) continueToolApprovalSession( @@ -429,6 +429,7 @@ func (s *Service) continueToolApprovalSession( approval toolapproval.Request, input ToolApprovalResponseInput, runID string, + runHandle sessionruntime.RunHandle, runtimeLifecycle *continuationLifecycleResult, eventCh chan<- WSStreamEvent, ) error { @@ -458,26 +459,10 @@ func (s *Service) continueToolApprovalSession( if err != nil { return err } - terminal := s.contextLifecycleTerminal(ctx, cfg) - var lifecycleCause error - var lifecycleDeferred bool - var terminalEventSeen bool - defer func() { - if runtimeLifecycle != nil { - runtimeLifecycle.cause = lifecycleCause - runtimeLifecycle.deferred = lifecycleDeferred - if snapshot, ok := cfg.ContextLifecycle.Snapshot(); ok { - runtimeLifecycle.snapshot = &snapshot - } - return - } - if !lifecycleDeferred { - terminal(lifecycleCause) - } - }() req := ChatRequest{ RunID: cfg.RunID, + RunHandle: runHandle, BotID: input.BotID, ChatID: input.BotID, ThreadID: approval.SessionID, @@ -490,112 +475,7 @@ func (s *Service) continueToolApprovalSession( WorkspaceTarget: workspaceTargetFromRunConfig(resolved.RunConfig), } - reasoningTiming := newReasoningTimingTracker(nil) - configureNativeReasoningTiming(&cfg, reasoningTiming, nil) - idleCtx, idleCancel := s.withStreamIdleTimeout(ctx, reasoningEffortForIdle(cfg)) - defer idleCancel.Stop() - stream := s.agent.Stream(idleCtx, cfg) - stored := false - failureEventForwarded := false - var hasVisibleOutput bool - for event := range stream { - idleCancel.Reset() - if event.Type == native.EventToolCallStart { - idleCancel.RecordToolCall() - } - if eventErr := agentStreamLifecycleError(event); eventErr != nil && lifecycleCause == nil { - lifecycleCause = eventErr - } - if event.IsTerminal() { - terminalEventSeen = true - lifecycleDeferred = pendingContinuationDecision(event) - if !lifecycleDeferred { - switch event.Type { - case native.EventAgentEnd: - lifecycleCause = nil - case native.EventAgentAbort: - if idleCancel.DidFire() { - lifecycleCause = context.Cause(idleCtx) - } else if context.Cause(ctx) != nil || lifecycleCause == nil { - lifecycleCause = agentAbortCause(ctx) - } - } - } - } - if hasVisibleAgentStreamOutput(event) { - hasVisibleOutput = true - } - if event.Type == native.EventAgentAbort && idleCancel.DidFire() && eventCh != nil { - if failureData, marshalErr := json.Marshal(agentFailureStreamEvent(context.Cause(idleCtx))); marshalErr == nil { - select { - case eventCh <- json.RawMessage(failureData): - failureEventForwarded = true - case <-ctx.Done(): - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - } - } - data, err := json.Marshal(publicAgentStreamEvent(event)) - if err != nil { - continue - } - if !stored && event.IsTerminal() && len(event.Messages) > 0 { - if snap, ok := extractTerminalSnapshot(data); ok { - snap.reasoningTiming = takeTerminalReasoningTiming(reasoningTiming, event.Type) - snap.visibleOutput = hasVisibleOutput - snap.failureCode = snapshotFailureCode(idleCancel.DidFire(), lifecycleCause) - lifecycleDeferred = lifecycleDeferred || snap.deferredToolID != "" - if snap.aborted && !lifecycleDeferred && lifecycleCause == nil { - lifecycleCause = agentAbortCause(ctx) - } - if storeErr := s.persistTerminalSnapshot( - context.WithoutCancel(ctx), - req, - resolvedContext{runConfig: cfg, model: models.GetResponse{ID: resolved.ModelID}}, - snap, - ); storeErr != nil { - lifecycleCause = storeErr - lifecycleDeferred = false - return storeErr - } - stored = true - } - } - if eventCh != nil && shouldForwardAfterIdleFailure(event, failureEventForwarded) { - select { - case eventCh <- json.RawMessage(data): - case <-ctx.Done(): - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - } - } - if idleCancel.DidFire() { - lifecycleCause = context.Cause(idleCtx) - if !stored { - if _, storeErr := s.persistTurnFailure(context.WithoutCancel(ctx), req, resolvedContext{runConfig: cfg, model: models.GetResponse{ID: resolved.ModelID}}, snapshotFailureCode(true, lifecycleCause)); storeErr != nil { - s.logger.Error("tool approval timeout persist failed", slog.Any("error", storeErr)) - } - } - if eventCh != nil && !failureEventForwarded { - if data, marshalErr := json.Marshal(agentFailureStreamEvent(lifecycleCause)); marshalErr == nil { - select { - case eventCh <- json.RawMessage(data): - case <-ctx.Done(): - } - } - } - return lifecycleCause - } - if ctx.Err() != nil { - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - if lifecycleCause == nil && !lifecycleDeferred && !terminalEventSeen { - lifecycleCause = errors.New("agent continuation ended without a terminal event") - } - return nil + return s.runNativeDecisionContinuation(ctx, req, cfg, resolved.ModelID, runtimeLifecycle, eventCh) } func withLocalWebReplyTarget(req toolapproval.Request) toolapproval.Request { diff --git a/internal/agent/application/service_trigger.go b/internal/agent/application/service_trigger.go index b1e7758cf..cc65bed9b 100644 --- a/internal/agent/application/service_trigger.go +++ b/internal/agent/application/service_trigger.go @@ -81,6 +81,7 @@ func (s *Service) TriggerSchedule(ctx context.Context, botID string, payload sch ChatID: botID, ThreadID: payload.SessionID, RunID: admission.RunID, + RunHandle: admission.Handle, Query: payload.Command, UserID: payload.OwnerUserID, Token: token, diff --git a/internal/agent/application/service_trigger_test.go b/internal/agent/application/service_trigger_test.go index 3a15b5773..ca79bd696 100644 --- a/internal/agent/application/service_trigger_test.go +++ b/internal/agent/application/service_trigger_test.go @@ -541,7 +541,7 @@ func (f *fakeTriggeredAdmitter) Admit(_ context.Context, input sessionruntime.Ad }, nil } -func (*fakeTriggeredAdmitter) FinishRun(context.Context, sessionruntime.RunHandle, string, string) error { +func (*fakeTriggeredAdmitter) FinishRunWithErrorCode(context.Context, sessionruntime.RunHandle, string, string) error { return nil } diff --git a/internal/agent/application/service_user_input.go b/internal/agent/application/service_user_input.go index cee91c7a8..75ae833ce 100644 --- a/internal/agent/application/service_user_input.go +++ b/internal/agent/application/service_user_input.go @@ -2,19 +2,16 @@ package application import ( "context" - "encoding/json" "errors" "fmt" - "log/slog" "strings" sdk "github.com/felinics/twilight/sdk" userinput "github.com/felinics/memoh/internal/agent/decision/input" - "github.com/felinics/memoh/internal/agent/runtime/native" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/bots" sessionpkg "github.com/felinics/memoh/internal/chat/thread" - "github.com/felinics/memoh/internal/models" "github.com/felinics/memoh/internal/workspace" ) @@ -70,6 +67,7 @@ type CommittedUserInputResponse struct { request userinput.Request input UserInputResponseInput runID string + runHandle sessionruntime.RunHandle activePrompt *externalAgentActivePromptSubscription isExternalAgent bool ackOnly bool @@ -201,7 +199,7 @@ func (s *Service) continueCommittedUserInputResponse( if s.continueUserInputFn != nil { return s.continueUserInputFn(ctx, resolved, committed.input, toolResult, eventCh) } - return s.storeUserInputResultAndContinue(ctx, resolved, committed.input, toolResult, runID, lifecycle, eventCh) + return s.storeUserInputResultAndContinue(ctx, resolved, committed.input, toolResult, runID, committed.runHandle, lifecycle, eventCh) } // isExternalAgentUserInputSession classifies a request by its session's runtime, the @@ -341,6 +339,7 @@ func (s *Service) storeUserInputResultAndContinue( input UserInputResponseInput, result sdk.ToolResultPart, runID string, + runHandle sessionruntime.RunHandle, lifecycle *continuationLifecycleResult, eventCh chan<- WSStreamEvent, ) error { @@ -361,13 +360,17 @@ func (s *Service) storeUserInputResultAndContinue( ReplyTarget: req.ReplyTarget, ConversationType: req.ConversationType, UserMessagePersisted: true, - WorkspaceTargetID: req.WorkspaceTargetID, - WorkspaceTarget: target, + // This write contains only the ask_user tool result. There is no new + // user history message to extract, so memory work must not attempt to + // resolve an empty PersistedUserMessageID. + SkipMemoryExtraction: true, + WorkspaceTargetID: req.WorkspaceTargetID, + WorkspaceTarget: target, } if err := s.storeRoundWithOptions(ctx, storeReq, modelMessages, "", storeRoundOptions{AllowPendingToolCalls: true}); err != nil { return err } - return s.continueUserInputSession(ctx, req, input, runID, lifecycle, eventCh) + return s.continueUserInputSession(ctx, req, input, runID, runHandle, lifecycle, eventCh) } func (s *Service) continueUserInputSession( @@ -375,6 +378,7 @@ func (s *Service) continueUserInputSession( req userinput.Request, input UserInputResponseInput, runID string, + runHandle sessionruntime.RunHandle, runtimeLifecycle *continuationLifecycleResult, eventCh chan<- WSStreamEvent, ) error { @@ -404,26 +408,10 @@ func (s *Service) continueUserInputSession( if err != nil { return err } - terminal := s.contextLifecycleTerminal(ctx, cfg) - var lifecycleCause error - var lifecycleDeferred bool - var terminalEventSeen bool - defer func() { - if runtimeLifecycle != nil { - runtimeLifecycle.cause = lifecycleCause - runtimeLifecycle.deferred = lifecycleDeferred - if snapshot, ok := cfg.ContextLifecycle.Snapshot(); ok { - runtimeLifecycle.snapshot = &snapshot - } - return - } - if !lifecycleDeferred { - terminal(lifecycleCause) - } - }() chatReq := ChatRequest{ RunID: cfg.RunID, + RunHandle: runHandle, BotID: input.BotID, ChatID: input.BotID, ThreadID: req.SessionID, @@ -432,116 +420,15 @@ func (s *Service) continueUserInputSession( ReplyTarget: req.ReplyTarget, ConversationType: req.ConversationType, UserMessagePersisted: true, - WorkspaceTargetID: req.WorkspaceTargetID, - WorkspaceTarget: workspaceTargetFromRunConfig(resolved.RunConfig), + // The user's answer is already represented by the persisted tool + // result above; the resumed invocation must not schedule a second + // user-message memory extraction with an empty message id. + SkipMemoryExtraction: true, + WorkspaceTargetID: req.WorkspaceTargetID, + WorkspaceTarget: workspaceTargetFromRunConfig(resolved.RunConfig), } - reasoningTiming := newReasoningTimingTracker(nil) - configureNativeReasoningTiming(&cfg, reasoningTiming, nil) - idleCtx, idleCancel := s.withStreamIdleTimeout(ctx, reasoningEffortForIdle(cfg)) - defer idleCancel.Stop() - stream := s.agent.Stream(idleCtx, cfg) - stored := false - failureEventForwarded := false - var hasVisibleOutput bool - for event := range stream { - idleCancel.Reset() - if event.Type == native.EventToolCallStart { - idleCancel.RecordToolCall() - } - if eventErr := agentStreamLifecycleError(event); eventErr != nil && lifecycleCause == nil { - lifecycleCause = eventErr - } - if event.IsTerminal() { - terminalEventSeen = true - lifecycleDeferred = pendingContinuationDecision(event) - if !lifecycleDeferred { - switch event.Type { - case native.EventAgentEnd: - lifecycleCause = nil - case native.EventAgentAbort: - if idleCancel.DidFire() { - lifecycleCause = context.Cause(idleCtx) - } else if context.Cause(ctx) != nil || lifecycleCause == nil { - lifecycleCause = agentAbortCause(ctx) - } - } - } - } - if hasVisibleAgentStreamOutput(event) { - hasVisibleOutput = true - } - if event.Type == native.EventAgentAbort && idleCancel.DidFire() && eventCh != nil { - if failureData, marshalErr := json.Marshal(agentFailureStreamEvent(context.Cause(idleCtx))); marshalErr == nil { - select { - case eventCh <- json.RawMessage(failureData): - failureEventForwarded = true - case <-ctx.Done(): - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - } - } - data, err := json.Marshal(publicAgentStreamEvent(event)) - if err != nil { - continue - } - if !stored && event.IsTerminal() && len(event.Messages) > 0 { - if snap, ok := extractTerminalSnapshot(data); ok { - snap.reasoningTiming = takeTerminalReasoningTiming(reasoningTiming, event.Type) - snap.visibleOutput = hasVisibleOutput - snap.failureCode = snapshotFailureCode(idleCancel.DidFire(), lifecycleCause) - lifecycleDeferred = lifecycleDeferred || snap.deferredToolID != "" - if snap.aborted && !lifecycleDeferred && lifecycleCause == nil { - lifecycleCause = agentAbortCause(ctx) - } - if storeErr := s.persistTerminalSnapshot( - context.WithoutCancel(ctx), - chatReq, - resolvedContext{runConfig: cfg, model: models.GetResponse{ID: resolved.ModelID}}, - snap, - ); storeErr != nil { - lifecycleCause = storeErr - lifecycleDeferred = false - return storeErr - } - stored = true - } - } - if eventCh != nil && shouldForwardAfterIdleFailure(event, failureEventForwarded) { - select { - case eventCh <- json.RawMessage(data): - case <-ctx.Done(): - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - } - } - if idleCancel.DidFire() { - lifecycleCause = context.Cause(idleCtx) - if !stored { - if _, storeErr := s.persistTurnFailure(context.WithoutCancel(ctx), chatReq, resolvedContext{runConfig: cfg, model: models.GetResponse{ID: resolved.ModelID}}, snapshotFailureCode(true, lifecycleCause)); storeErr != nil { - s.logger.Error("user input timeout persist failed", slog.Any("error", storeErr)) - } - } - if eventCh != nil && !failureEventForwarded { - if data, marshalErr := json.Marshal(agentFailureStreamEvent(lifecycleCause)); marshalErr == nil { - select { - case eventCh <- json.RawMessage(data): - case <-ctx.Done(): - } - } - } - return lifecycleCause - } - if ctx.Err() != nil { - lifecycleCause = context.Cause(ctx) - return lifecycleCause - } - if lifecycleCause == nil && !lifecycleDeferred && !terminalEventSeen { - lifecycleCause = errors.New("agent continuation ended without a terminal event") - } - return nil + return s.runNativeDecisionContinuation(ctx, chatReq, cfg, resolved.ModelID, runtimeLifecycle, eventCh) } func withLocalWebUserInputReplyTarget(req userinput.Request) userinput.Request { diff --git a/internal/agent/application/service_user_input_test.go b/internal/agent/application/service_user_input_test.go index ecc906766..d5cd595d8 100644 --- a/internal/agent/application/service_user_input_test.go +++ b/internal/agent/application/service_user_input_test.go @@ -17,6 +17,7 @@ import ( "github.com/felinics/memoh/internal/agent/turn" "github.com/felinics/memoh/internal/bots" session "github.com/felinics/memoh/internal/chat/thread" + sessiontest "github.com/felinics/memoh/internal/testutil/sessionruntime" ) const testACPUserInputOwnerID = "owner-user" @@ -363,7 +364,7 @@ func TestRuntimeUserInputCommandCommitsAndResumesSameRun(t *testing.T) { return sendAgentStreamEvent(ctx, eventCh, native.StreamEvent{Type: native.EventAgentEnd}) }, } - manager := sessionruntime.NewManager(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ + manager := sessiontest.New(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ OwnerID: "owner-1", StateTTL: time.Minute, OwnerLeaseTTL: time.Second, @@ -374,8 +375,7 @@ func TestRuntimeUserInputCommandCommitsAndResumesSameRun(t *testing.T) { t.Fatalf("start runtime manager: %v", err) } resolver.SetSessionRuntime(manager) - if err := manager.StartRun( - context.Background(), + if _, err := sessiontest.Start(context.Background(), manager, botID, sessionID, runID, @@ -415,17 +415,13 @@ func TestRuntimeUserInputCommandCommitsAndResumesSameRun(t *testing.T) { if err != nil { t.Fatalf("encode response: %v", err) } - handled, err := manager.DispatchRunCommand( - context.Background(), - botID, - sessionID, - runID, - sessionruntime.CommandUserInputResponse, - inputID, - payload, - ) - if err != nil || !handled { - t.Fatalf("dispatch response = handled:%v err:%v", handled, err) + err = resolver.handleRuntimeDecisionCommand(context.Background(), sessionruntime.Command{ + ID: "control-early-ack", Type: sessionruntime.CommandUserInputResponse, + BotID: botID, SessionID: sessionID, RunID: runID, + Generation: handle.Generation, TargetID: inputID, Payload: payload, + }) + if err != nil { + t.Fatalf("handle decision command: %v", err) } if fake.submitCalls != 1 { t.Fatalf("submit calls = %d, want 1 before acknowledgement", fake.submitCalls) diff --git a/internal/agent/application/session_queue.go b/internal/agent/application/session_queue.go new file mode 100644 index 000000000..b2987c730 --- /dev/null +++ b/internal/agent/application/session_queue.go @@ -0,0 +1,146 @@ +package application + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" +) + +// SessionQueues is the application surface for user-facing queue operations. +// Items are transient and live in the configured memory or Redis runtime. +type SessionQueues struct { + SteerSupported bool + Steer []sessionruntime.SteerItem + FollowUp []sessionruntime.FollowUpItem +} + +func (s *Service) liveQueueRuntime() (*sessionruntime.Manager, error) { + if s == nil || s.sessionManager == nil { + return nil, sessionruntime.ErrLiveQueueUnavailable + } + return s.sessionManager, nil +} + +func (s *Service) EnqueueSteer(ctx context.Context, botID, sessionID, invocationID string, payload []byte) (sessionruntime.SteerItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return sessionruntime.SteerItem{}, err + } + return runtime.EnqueueSteer(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, uuid.NewString(), invocationID, payload) +} + +func (s *Service) EnqueueFollowUp(ctx context.Context, botID, sessionID, invocationID string, payload []byte) (sessionruntime.FollowUpItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return sessionruntime.FollowUpItem{}, err + } + item, err := runtime.EnqueueFollowUp(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, uuid.NewString(), invocationID, payload) + if err != nil { + return sessionruntime.FollowUpItem{}, err + } + if item.Status == sessionruntime.QueueAccepted { + s.kickFollowUpIfIdle(ctx, botID, sessionID, item.EnqueuedDuringRunID) + } + return item, nil +} + +func (s *Service) ListSessionQueues(ctx context.Context, botID, sessionID string) (SessionQueues, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return SessionQueues{}, err + } + steers, followUps, err := runtime.PendingQueues(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, 0) + if err != nil { + return SessionQueues{}, err + } + snapshot, err := runtime.Snapshot(ctx, botID, sessionID) + if err != nil { + return SessionQueues{}, err + } + return SessionQueues{Steer: steers, FollowUp: followUps, SteerSupported: sessionruntime.SteerRunAvailable(snapshot.CurrentRunView)}, nil +} + +func (s *Service) ReorderSteer(ctx context.Context, botID, sessionID string, item, before sessionruntime.SteerPendingRef) ([]sessionruntime.SteerItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return nil, err + } + return runtime.ReorderSteer(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, item, before) +} + +func (s *Service) ReorderFollowUp(ctx context.Context, botID, sessionID string, item, before sessionruntime.FollowUpPendingRef) ([]sessionruntime.FollowUpItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return nil, err + } + return runtime.ReorderFollowUp(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, item, before) +} + +func (s *Service) UpdateSteer(ctx context.Context, botID, sessionID, itemID string, payload []byte) (sessionruntime.SteerItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return sessionruntime.SteerItem{}, err + } + return runtime.UpdateSteer(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, sessionruntime.SteerItemID(itemID), payload) +} + +func (s *Service) UpdateFollowUp(ctx context.Context, botID, sessionID, itemID string, payload []byte) (sessionruntime.FollowUpItem, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return sessionruntime.FollowUpItem{}, err + } + key := sessionruntime.Key{BotID: botID, SessionID: sessionID} + _, items, err := runtime.PendingQueues(ctx, key, 0) + if err != nil { + return sessionruntime.FollowUpItem{}, err + } + for _, item := range items { + if string(item.ID) != itemID { + continue + } + body := decodeFollowUpPayload(item.Payload) + if body.Command != nil { + text := QueuePayloadText(payload) + body.Text = text + body.Command.Query = text + body.Command.ModelQuery = "" + body.Command.UserVisibleText = text + payload, err = json.Marshal(body) + if err != nil { + return sessionruntime.FollowUpItem{}, err + } + } + // Routing and attachment metadata are immutable for a queued command. + // The backend rechecks accepted status atomically with the edit, so a + // concurrent claim/cancel/promotion still rejects this write. + return runtime.UpdateFollowUp(ctx, key, item.ID, payload) + } + return sessionruntime.FollowUpItem{}, sessionruntime.ErrQueueNotPending +} + +func (s *Service) CancelSteer(ctx context.Context, botID, sessionID, itemID string) error { + runtime, err := s.liveQueueRuntime() + if err != nil { + return err + } + return runtime.CancelSteer(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, sessionruntime.SteerItemID(itemID)) +} + +func (s *Service) CancelFollowUp(ctx context.Context, botID, sessionID, itemID string) error { + runtime, err := s.liveQueueRuntime() + if err != nil { + return err + } + return runtime.CancelFollowUp(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, sessionruntime.FollowUpItemID(itemID)) +} + +func (s *Service) PromoteFollowUpToSteer(ctx context.Context, botID, sessionID string, followUp sessionruntime.FollowUpPendingRef) (sessionruntime.PromoteFollowUpResult, error) { + runtime, err := s.liveQueueRuntime() + if err != nil { + return sessionruntime.PromoteFollowUpResult{}, err + } + return runtime.PromoteFollowUpToSteer(ctx, sessionruntime.Key{BotID: botID, SessionID: sessionID}, followUp) +} diff --git a/internal/agent/application/step_commit.go b/internal/agent/application/step_commit.go index 1a797d501..0e875ef04 100644 --- a/internal/agent/application/step_commit.go +++ b/internal/agent/application/step_commit.go @@ -4,24 +4,33 @@ import ( "context" "errors" "fmt" + "log/slog" "strings" "sync" + "sync/atomic" sdk "github.com/felinics/twilight/sdk" + "github.com/felinics/memoh/internal/agent/runtime/native" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + chatview "github.com/felinics/memoh/internal/agent/view" messagepkg "github.com/felinics/memoh/internal/chat/message" "github.com/felinics/memoh/internal/runtimefence" ) // agentStepCommitter bridges Twilight's complete-step barrier to history // persistence. It is intentionally enabled only for admitted, fenced turns; -// legacy calls and replacement flows keep their terminal-snapshot behavior. +// legacy calls without a runtime owner keep their terminal-snapshot behavior. type agentStepCommitter struct { - service *Service - req ChatRequest - rc resolvedContext - persister messagepkg.AgentStepPersister - reasoningTiming *reasoningTimingTracker + ownerContext context.Context + service *Service + req ChatRequest + rc resolvedContext + persister messagepkg.AgentStepPersister + reasoningTiming *reasoningTimingTracker + queueStep *queueStepCoordinator + continueAfterFinal atomic.Bool + nextModelInputs []sdk.Message mu sync.Mutex turnRequestMessageID string @@ -31,12 +40,13 @@ type agentStepCommitter struct { nextStep int // In-process ordering guard, not a durable replay cursor. commitErr error finalized bool + replacementFinalized bool } func (s *Service) newAgentStepCommitter(ctx context.Context, req ChatRequest, rc resolvedContext) *agentStepCommitter { if s == nil || s.messageService == nil || strings.TrimSpace(req.RunID) == "" || strings.TrimSpace(req.BotID) == "" || strings.TrimSpace(req.ThreadID) == "" || - req.SkipHistoryTurn || req.ReusePersistedUserMessage { + ((req.SkipHistoryTurn || req.ReusePersistedUserMessage) && req.TurnReplacement == nil) { return nil } if _, ok := runtimefence.FromContext(ctx); !ok { @@ -46,33 +56,89 @@ func (s *Service) newAgentStepCommitter(ctx context.Context, req ChatRequest, rc if !ok { return nil } + queueStep := newQueueStepCoordinator(s, req) + if queueStep != nil && queueStep.steerEnabled { + if err := s.sessionManager.EnableSteer(ctx, req.RunHandle); err != nil { + queueStep.steerEnabled = false + if s.logger != nil { + s.logger.Warn("steer consumer could not be published", slog.String("run_id", req.RunID), slog.Any("error", err)) + } + } + } + if req.TurnReplacement != nil && queueStep == nil { + return nil + } requestMessageID := "" - if req.UserMessagePersisted { + switch { + case req.ReusePersistedUserMessage: requestMessageID = strings.TrimSpace(req.PersistedUserMessageID) if requestMessageID == "" { return nil } - } else if strings.TrimSpace(req.TurnID) == "" || req.TurnPosition == nil { + case req.UserMessagePersisted: + requestMessageID = strings.TrimSpace(req.PersistedUserMessageID) + case strings.TrimSpace(req.TurnID) == "" || req.TurnPosition == nil: return nil } return &agentStepCommitter{ - service: s, req: req, rc: rc, persister: persister, + service: s, req: req, rc: rc, persister: persister, ownerContext: ctx, + queueStep: queueStep, turnRequestMessageID: requestMessageID, + nextStep: req.StepIndexOffset, } } +func (c *agentStepCommitter) bindContinuation(cfg *native.RunConfig) { + if c == nil || cfg == nil { + return + } + cfg.ContinueAfterFinal = &c.continueAfterFinal + cfg.NextModelInputs = &c.nextModelInputs + if c.queueStep != nil && c.queueStep.steerEnabled { + cfg.SteerWake = c.service.sessionManager.SteerWake(c.req.RunHandle) + cfg.PendingSteer = func(ctx context.Context) (bool, error) { + items, _, err := c.service.sessionManager.PendingQueues(ctx, sessionruntime.Key{ + BotID: c.req.BotID, SessionID: c.req.ThreadID, + }, sessionruntime.MaxPendingQueueItems) + for _, item := range items { + if item.TargetRunID == c.req.RunID && item.Status == sessionruntime.QueueAccepted { + return true, err + } + } + return false, err + } + cfg.OnSteer = func(ctx context.Context, index int, step *sdk.StepResult) error { + return c.persist(ctx, index, step, stepSteered) + } + } +} + +type stepCommitMode uint8 + +const ( + stepCompleted stepCommitMode = iota + stepInterrupted + stepSteered +) + func (c *agentStepCommitter) commit(ctx context.Context, stepIndex int, step *sdk.StepResult) error { - return c.persist(ctx, stepIndex, step, false) + return c.persist(ctx, stepIndex, step, stepCompleted) } func (c *agentStepCommitter) interrupt(ctx context.Context, stepIndex int, step *sdk.StepResult) error { - return c.persist(ctx, stepIndex, step, true) + return c.persist(ctx, stepIndex, step, stepInterrupted) } -func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *sdk.StepResult, interrupted bool) error { +func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *sdk.StepResult, mode stepCommitMode) error { + interrupted := mode != stepCompleted if c == nil || step == nil { return errors.New("agent step is missing") } + persistCtx, ownershipErr := stepPersistenceContext(ctx, c.ownerContext) + if ownershipErr != nil { + return ownershipErr + } + ctx = persistCtx messages := sdkMessagesToModelMessages(step.Messages) timingState := "completed" if interrupted { @@ -86,7 +152,7 @@ func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *s // recorded as a commit failure: the turn is already ending, and losing an // unfinished snapshot must not turn an abort into a turn error. fail := func(err error) error { - if !interrupted { + if mode != stepInterrupted { c.commitErr = err } return err @@ -94,11 +160,16 @@ func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *s if stepIndex != c.nextStep { return fail(fmt.Errorf("unexpected agent step %d, want %d", stepIndex, c.nextStep)) } - if !hasPersistableAssistantOutput(messages) { + hasAssistantOutput := hasPersistableAssistantOutput(messages) + // A durable run must still cross the coordinator boundary for an empty + // provider result: final handoff and queue reconciliation are keyed to the + // step, not to whether the provider emitted a message. Legacy/non-durable + // paths retain the old cheap no-op behavior. + if !hasAssistantOutput && mode != stepSteered && (c.queueStep == nil || interrupted) { c.nextStep++ return nil } - if stepIndex == 0 && !c.req.UserMessagePersisted { + if (hasAssistantOutput || mode == stepSteered) && stepIndex == 0 && !c.req.UserMessagePersisted && !c.req.ReusePersistedUserMessage { messages = prependTurnUserMessage(c.req, messages) } storeReq := c.req @@ -119,20 +190,58 @@ func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *s } } opts = opts.withContextLifecycleMetadata(c.service.logger, storeReq, messages) - inputs, err := c.service.buildPersistInputs(context.WithoutCancel(ctx), storeReq, messages, c.rc.model.ID, opts) - if err != nil { - return fail(err) + var ( + inputs []messagepkg.PersistInput + err error + ) + if len(messages) > 0 { + inputs, err = c.service.buildPersistInputs(context.WithoutCancel(ctx), storeReq, messages, c.rc.model.ID, opts) + if err != nil { + return fail(err) + } } for i := range inputs { inputs[i].TurnRequestMessageID = c.turnRequestMessageID } - persisted, err := c.persister.PersistAgentStep(context.WithoutCancel(ctx), messagepkg.AgentStep{ - RunID: c.req.RunID, Messages: inputs, Interrupted: interrupted, - }) + agentStep := messagepkg.AgentStep{RunID: c.req.RunID, Messages: inputs, Interrupted: interrupted} + var persisted []messagepkg.Message + var queueErr error + if c.queueStep != nil && mode != stepInterrupted { + stepCtx := context.WithoutCancel(ctx) + kind := classifyQueueStep(step) + if mode == stepSteered { + kind = queueStepSteered + } + outcome, commitErr := c.queueStep.commit( + stepCtx, kind, agentStep, c.persisted, + ) + if !outcome.historyCommitted { + return fail(commitErr) + } + // History is already durable even if later queue coordination failed. + // Record that prefix below before returning the error; a cleanup must + // not lose it or write the same step again. + queueErr = commitErr + persisted = outcome.persisted + c.replacementFinalized = outcome.replacementFinalized + if queueErr == nil { + if outcome.claimedSteer != nil { + c.nextModelInputs = append(c.nextModelInputs, sdk.UserMessage(QueuePayloadText(outcome.claimedSteer.Payload))) + } + c.continueAfterFinal.Store(outcome.continueAfterFinal) + } + if queueErr == nil { + c.publishQueueUserTurns(context.WithoutCancel(ctx), stepIndex, outcome) + } + } else { + persisted, err = c.persister.PersistAgentStep(context.WithoutCancel(ctx), agentStep) + } if err != nil { return fail(err) } - c.rc.runConfig.ContextLifecycle.SetAssistantMessageID(lastPersistedAssistantMessageID(persisted)) + if len(persisted) > 0 { + c.rc.runConfig.ContextLifecycle.SetAssistantMessageID(lastPersistedAssistantMessageID(persisted)) + } c.nextStep++ for _, message := range persisted { if strings.EqualFold(strings.TrimSpace(message.Role), "user") { @@ -140,15 +249,62 @@ func (c *agentStepCommitter) persist(ctx context.Context, stepIndex int, step *s } } c.persisted = append(c.persisted, persisted...) + if c.replacementFinalized { + c.service.publishReplacementMessageCreated(c.req.BotID, c.persisted) + } if !interrupted { // Unfinished reasoning/text is history context, not a fact source for // asynchronous long-term memory extraction. c.memoryPersisted = append(c.memoryPersisted, persisted...) c.messages = append(c.messages, messages...) } + if queueErr != nil { + return fail(queueErr) + } return nil } +func (c *agentStepCommitter) publishQueueUserTurns(ctx context.Context, stepIndex int, outcome queueStepOutcome) { + if c == nil || c.service == nil || c.service.sessionManager == nil { + return + } + projected := chatview.ConvertMessagesToUITurns(outcome.persisted) + userTurns := make([]chatview.UITurn, 0, len(projected)) + for _, turn := range projected { + if strings.EqualFold(strings.TrimSpace(turn.Role), "user") { + userTurns = append(userTurns, turn) + } + } + update := sessionruntime.QueueUserTurnUpdate{ + PersistedTurns: userTurns, + AppliedSteerItemID: strings.TrimSpace(outcome.appliedSteerItemID), + } + if update.AppliedSteerItemID != "" && len(userTurns) > 0 { + // A queue claim is the only mid-run user input admitted by this adapter. + // The step capture places it after any earlier durable users, so the last + // persisted user is the history identity for the applied item. + applied := userTurns[len(userTurns)-1] + update.AppliedSteerTurn = &applied + } + if outcome.claimedSteer != nil { + update.ClaimedSteerItemID = string(outcome.claimedSteer.ID) + update.ClaimedSteerText = QueuePayloadText(outcome.claimedSteer.Payload) + update.ClaimedSteerTimestamp = outcome.claimedSteer.CreatedAt + // Anchor after the step that just committed. Its step_end marker was + // emitted by the native loop before the commit barrier ran, so the wait + // only covers event consumption and is bounded. + after := stepIndex + update.AfterStepIndex = &after + } + if len(update.PersistedTurns) == 0 && update.AppliedSteerItemID == "" && update.ClaimedSteerItemID == "" { + return + } + if err := c.service.sessionManager.PublishQueueUserTurns(ctx, c.req.RunHandle, update); err != nil && c.service.logger != nil { + c.service.logger.Warn("publish runtime queue user turns failed", + slog.String("run_id", c.req.RunID), slog.Any("error", err)) + } +} + func (c *agentStepCommitter) err() error { if c == nil { return nil @@ -204,3 +360,13 @@ func (c *agentStepCommitter) persistedMessages() []messagepkg.Message { defer c.mu.Unlock() return append([]messagepkg.Message(nil), c.persisted...) } + +// The run's owner context remains authoritative even when the SDK supplies a +// detached cleanup context. User abort checkpoints may outlive cancellation; +// a revoked owner must never write in the reaper's grace window. +func stepPersistenceContext(ctx, owner context.Context) (context.Context, error) { + if runOwnershipLost(ctx) || runOwnershipLost(owner) { + return nil, sessionruntime.ErrRunOwnershipLost + } + return context.WithoutCancel(ctx), nil +} diff --git a/internal/agent/application/step_commit_test.go b/internal/agent/application/step_commit_test.go deleted file mode 100644 index b709a64ab..000000000 --- a/internal/agent/application/step_commit_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package application - -import ( - "context" - "testing" - "time" - - sdk "github.com/felinics/twilight/sdk" - "github.com/google/uuid" - - contextfrag "github.com/felinics/memoh/internal/agent/context/fragment" - "github.com/felinics/memoh/internal/agent/runtime/native" - messagepkg "github.com/felinics/memoh/internal/chat/message" - "github.com/felinics/memoh/internal/models" - "github.com/felinics/memoh/internal/runtimefence" -) - -type recordingStepPersister struct { - *recordingMessageService - steps []messagepkg.AgentStep -} - -func (s *recordingStepPersister) PersistAgentStep(_ context.Context, step messagepkg.AgentStep) ([]messagepkg.Message, error) { - s.steps = append(s.steps, step) - result := make([]messagepkg.Message, len(step.Messages)) - for i, input := range step.Messages { - result[i] = messagepkg.Message{ID: "committed", Role: input.Role} - } - return result, nil -} - -func TestAgentStepCommitterPersistsOnlyStepDelta(t *testing.T) { - botID, sessionID, runID, turnID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() - position := int64(4) - store := &recordingStepPersister{recordingMessageService: &recordingMessageService{}} - service := &Service{messageService: store} - holder := contextfrag.NewLifecycleHolder() - holder.SetManifest(contextfrag.Manifest{View: contextfrag.ViewRunConfigPreProvider}) - req := ChatRequest{BotID: botID, ThreadID: sessionID, RunID: runID, TurnID: turnID, TurnPosition: &position, Query: "hello", SkipMemoryExtraction: true} - ctx := runtimefence.WithContext(context.Background(), runtimefence.Fence{BotID: botID, SessionID: sessionID, Token: 7}) - rc := resolvedContext{model: models.GetResponse{ID: uuid.NewString()}} - rc.runConfig.ContextLifecycle = holder - committer := service.newAgentStepCommitter(ctx, req, rc) - if committer == nil { - t.Fatal("step committer was not enabled for an admitted fenced turn") - } - clock := newReasoningTimingTestClock() - committer.reasoningTiming = newReasoningTimingTracker(clock.read) - for i, text := range []string{"first", "second"} { - if err := committer.commit(ctx, i, &sdk.StepResult{Messages: []sdk.Message{sdk.AssistantMessage(text)}}); err != nil { - t.Fatalf("commit step %d: %v", i, err) - } - } - partial := sdk.Message{Role: sdk.MessageRoleAssistant, Content: []sdk.MessagePart{sdk.ReasoningPart{Text: "partial reasoning"}}} - committer.reasoningTiming.observe(native.StreamEvent{Type: native.EventReasoningDelta, Delta: "partial reasoning"}) - clock.advance(1500 * time.Millisecond) - if err := committer.interrupt(ctx, 2, &sdk.StepResult{Messages: []sdk.Message{partial}}); err != nil { - t.Fatalf("persist interrupted step: %v", err) - } - if len(store.steps) != 3 || len(store.steps[0].Messages) != 2 || len(store.steps[1].Messages) != 1 || !store.steps[2].Interrupted { - t.Fatalf("persisted steps = %#v, want two complete plus one interrupted", store.steps) - } - if store.steps[2].Messages[0].Metadata[messagepkg.AgentStepInterruptedMetadataKey] != true { - t.Fatalf("interrupted metadata = %#v", store.steps[2].Messages[0].Metadata) - } - timings := messagepkg.ReasoningTimingFromMetadata(store.steps[2].Messages[0].Metadata) - if len(timings) != 1 || timings[0].DurationMS != 1500 || timings[0].State != "interrupted" { - t.Fatalf("interrupted reasoning timing = %#v", timings) - } - if _, ok := store.steps[0].Messages[1].Metadata[contextfrag.MetadataContextLifecycleKey]; !ok { - t.Fatalf("first step lifecycle metadata = %#v", store.steps[0].Messages[1].Metadata) - } - snapshot, ok := holder.Snapshot() - if !ok || snapshot.AssistantMessageID != "committed" { - t.Fatalf("lifecycle snapshot = %#v, set = %v", snapshot, ok) - } - if got := store.steps[1].Messages[0].TurnRequestMessageID; got != "committed" { - t.Fatalf("second step request message = %q, want first committed user", got) - } - if len(committer.messages) != 3 { - t.Fatalf("memory messages = %d, want interrupted output excluded", len(committer.messages)) - } -} diff --git a/internal/agent/application/step_ownership_test.go b/internal/agent/application/step_ownership_test.go new file mode 100644 index 000000000..994b31fb0 --- /dev/null +++ b/internal/agent/application/step_ownership_test.go @@ -0,0 +1,47 @@ +package application + +import ( + "context" + "errors" + "testing" + + sdk "github.com/felinics/twilight/sdk" + + contextfrag "github.com/felinics/memoh/internal/agent/context/fragment" + "github.com/felinics/memoh/internal/agent/runtime/native" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" +) + +func TestCheckpointDistinguishesUserAbortFromRevokedOwner(t *testing.T) { + for _, cause := range []error{context.Canceled, sessionruntime.ErrRunOwnershipLost} { + t.Run(cause.Error(), func(t *testing.T) { + store := &recordingStepPersister{recordingMessageService: &recordingMessageService{}} + owner, cancel := context.WithCancelCause(context.Background()) + committer := &agentStepCommitter{service: &Service{}, persister: store, ownerContext: owner, req: ChatRequest{BotID: "bot", ThreadID: "session", RunID: "run", UserMessagePersisted: true}, rc: resolvedContext{runConfig: native.RunConfig{ContextLifecycle: contextfrag.NewLifecycleHolder()}}} + cancel(cause) + err := committer.interrupt(context.WithoutCancel(owner), 0, &sdk.StepResult{Messages: []sdk.Message{sdk.AssistantMessage("partial")}}) + if errors.Is(cause, sessionruntime.ErrRunOwnershipLost) { + if !errors.Is(err, cause) || len(store.steps) != 0 { + t.Fatalf("revoked checkpoint: writes=%d err=%v", len(store.steps), err) + } + } else if err != nil || len(store.steps) != 1 { + t.Fatalf("user abort checkpoint: writes=%d err=%v", len(store.steps), err) + } + }) + } +} + +func TestSubagentRejectsRevokedCheckpointWithDetachedCallback(t *testing.T) { + store := &recordingStepPersister{recordingMessageService: &recordingMessageService{}} + service := &Service{messageService: store} + owner, cancel := context.WithCancelCause(subagentRunContext("bot", "session", 9)) + _, checkpoint := service.SubagentStepCommit(owner, "bot", "session", "model", "request", nil, nil) + if checkpoint == nil { + t.Fatal("missing checkpoint callback") + } + cancel(sessionruntime.ErrRunOwnershipLost) + err := checkpoint(context.WithoutCancel(owner), 0, &sdk.StepResult{Messages: []sdk.Message{sdk.AssistantMessage("late")}}) + if !errors.Is(err, sessionruntime.ErrRunOwnershipLost) || len(store.steps) != 0 { + t.Fatalf("revoked subagent checkpoint: writes=%d err=%v", len(store.steps), err) + } +} diff --git a/internal/agent/application/step_persister_test.go b/internal/agent/application/step_persister_test.go new file mode 100644 index 000000000..1640b4849 --- /dev/null +++ b/internal/agent/application/step_persister_test.go @@ -0,0 +1,35 @@ +package application + +import ( + "context" + + messagepkg "github.com/felinics/memoh/internal/chat/message" +) + +// recordingStepPersister records the history boundary shared by native and +// subagent step tests, with an optional persistence failure. +type recordingStepPersister struct { + *recordingMessageService + steps []messagepkg.AgentStep + stepErr error +} + +func (s *recordingStepPersister) PersistAgentStep(_ context.Context, step messagepkg.AgentStep) ([]messagepkg.Message, error) { + if s.stepErr != nil { + return nil, s.stepErr + } + s.steps = append(s.steps, step) + result := make([]messagepkg.Message, len(step.Messages)) + for i, input := range step.Messages { + result[i] = messagepkg.Message{ID: "committed", Role: input.Role, BotID: input.BotID, SessionID: input.SessionID, Metadata: input.Metadata, Content: input.Content} + } + return result, nil +} + +func (s *recordingStepPersister) PersistAgentReplacementStep(ctx context.Context, step messagepkg.AgentStep) ([]messagepkg.Message, error) { + return s.PersistAgentStep(ctx, step) +} + +func (*recordingStepPersister) FinalizeAgentReplacement(context.Context, string, messagepkg.TurnReplacement, string, string) error { + return nil +} diff --git a/internal/agent/application/subagent_abort_control_alignment_test.go b/internal/agent/application/subagent_abort_control_alignment_test.go index 73bd32c65..28b2285d1 100644 --- a/internal/agent/application/subagent_abort_control_alignment_test.go +++ b/internal/agent/application/subagent_abort_control_alignment_test.go @@ -30,7 +30,7 @@ func (b *terminalLoadFaultBackend) Load(ctx context.Context, key sessionruntime. } func TestSpawnCleanEndRacingAbortControlAlignsAllTerminals(t *testing.T) { - runs := &abortAlignmentLedger{} + runs := newAbortAlignmentLedger() backend := &terminalLoadFaultBackend{MemoryBackend: sessionruntime.NewMemoryBackend()} manager := sessionruntime.NewManager(backend, sessionruntime.Options{ OwnerID: "clean-end-abort-owner", diff --git a/internal/agent/application/subagent_runtime_alignment_test.go b/internal/agent/application/subagent_runtime_alignment_test.go index 5cc607e60..853f08ea2 100644 --- a/internal/agent/application/subagent_runtime_alignment_test.go +++ b/internal/agent/application/subagent_runtime_alignment_test.go @@ -3,7 +3,6 @@ package application import ( "context" "errors" - "sync" "testing" "time" @@ -13,6 +12,7 @@ import ( sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/agent/runtime/session/ledger" tools "github.com/felinics/memoh/internal/agent/tool" + "github.com/felinics/memoh/internal/testutil/sessionledger" ) type abortAlignmentProvider struct { @@ -57,174 +57,10 @@ type abortAlignmentFence struct{} func (abortAlignmentFence) Activate(context.Context, string, string, int64) error { return nil } -type abortAlignmentLedger struct { - mu sync.Mutex - run ledger.Run - token int64 -} - -var ( - _ sessionruntime.FenceActivator = abortAlignmentFence{} - _ ledger.Store = (*abortAlignmentLedger)(nil) -) - -func (s *abortAlignmentLedger) Admit(_ context.Context, params ledger.AdmitParams) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != "" { - if s.run.SessionID == params.SessionID && s.run.InvocationID == params.InvocationID { - return s.run, false, nil - } - if s.run.SessionID == params.SessionID && s.run.State.Active() { - return ledger.Run{}, false, ledger.ErrSessionBusy - } - } - s.run = ledger.Run{ - RunID: params.RunID, - BotID: params.BotID, - SessionID: params.SessionID, - InvocationID: params.InvocationID, - TurnID: params.TurnID, - TurnPosition: 1, - State: ledger.StateAccepted, - Input: append([]byte(nil), params.Input...), - InputFingerprint: params.InputFingerprint, - CreatedAt: time.Now(), - } - return s.run, true, nil -} - -func (s *abortAlignmentLedger) Get(_ context.Context, runID string) (ledger.Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != runID { - return ledger.Run{}, ledger.ErrRunNotFound - } - return s.run, nil -} - -func (s *abortAlignmentLedger) GetByInvocation(_ context.Context, sessionID, invocationID string) (ledger.Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.SessionID != sessionID || s.run.InvocationID != invocationID { - return ledger.Run{}, ledger.ErrRunNotFound - } - return s.run, nil -} - -func (s *abortAlignmentLedger) ActiveRun(_ context.Context, sessionID string) (ledger.Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.SessionID != sessionID || !s.run.State.Active() { - return ledger.Run{}, ledger.ErrRunNotFound - } - return s.run, nil -} - -func (s *abortAlignmentLedger) LatestRun(_ context.Context, sessionID string) (ledger.Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.SessionID != sessionID { - return ledger.Run{}, ledger.ErrRunNotFound - } - return s.run, nil -} - -func (s *abortAlignmentLedger) NextFencingToken(context.Context) (int64, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.token++ - return s.token, nil -} - -func (s *abortAlignmentLedger) Claim(_ context.Context, params ledger.ClaimParams) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != params.RunID || s.run.State != ledger.StateAccepted { - return ledger.Run{}, false, nil - } - s.run.State = ledger.StateRunning - s.run.OwnerID = params.OwnerID - s.run.FencingToken = params.FencingToken - s.run.LiveGeneration = params.LiveGeneration - s.run.OwnerSince = time.Now() - return s.run, true, nil -} - -func (s *abortAlignmentLedger) SetWaitingDecision(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { - return s.transition(runID, token, ledger.StateWaitingDecision) -} - -func (s *abortAlignmentLedger) Resume(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { - return s.transition(runID, token, ledger.StateRunning) -} - -func (s *abortAlignmentLedger) transition(runID string, token int64, state ledger.State) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != runID || s.run.FencingToken != token || s.run.State.Terminal() || s.run.State == ledger.StateFinishing { - return ledger.Run{}, false, nil - } - s.run.State = state - return s.run, true, nil -} - -func (s *abortAlignmentLedger) PrepareFinish(_ context.Context, params ledger.PrepareFinishParams) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != params.RunID || s.run.FencingToken != params.FencingToken || s.run.State.Terminal() || - (s.run.State == ledger.StateWaitingDecision && !params.AllowWaitingDecision) { - return s.run, false, nil - } - if s.run.State != ledger.StateFinishing { - s.run.State = ledger.StateFinishing - s.run.ProposedState = params.State - s.run.ProposedErrorCode = params.ErrorCode - s.run.ProposedErrorMessage = params.ErrorMessage - s.run.FinishProposedAt = time.Now() - } - return s.run, true, nil -} - -func (s *abortAlignmentLedger) Finalize(_ context.Context, params ledger.FinalizeParams) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != params.RunID || s.run.FencingToken != params.FencingToken || s.run.State.Terminal() { - return s.run, false, nil - } - if s.run.State == ledger.StateFinishing { - s.run.State = s.run.ProposedState - s.run.ErrorCode = s.run.ProposedErrorCode - s.run.ErrorMessage = s.run.ProposedErrorMessage - } else { - s.run.State = params.State - s.run.ErrorCode = params.ErrorCode - s.run.ErrorMessage = params.ErrorMessage - } - s.run.UpdatedAt = time.Now() - return s.run, true, nil -} - -func (s *abortAlignmentLedger) RequestAbort(_ context.Context, runID string) (ledger.Run, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.run.RunID != runID || s.run.State.Terminal() || s.run.State == ledger.StateFinishing { - return s.run, false, nil - } - s.run.AbortRequestedAt = time.Now() - return s.run, true, nil -} - -func (*abortAlignmentLedger) StaleGenerationRuns(context.Context, ledger.StaleGenerationQuery) ([]ledger.Run, error) { - return nil, nil -} - -func (*abortAlignmentLedger) OrphanedRuns(context.Context, ledger.OrphanQuery) ([]ledger.Run, error) { - return nil, nil -} +func newAbortAlignmentLedger() *sessionledger.Store { return sessionledger.New() } func TestSpawnAbortAlignsManagerLedgerAndLifecycle(t *testing.T) { - runs := &abortAlignmentLedger{} + runs := newAbortAlignmentLedger() manager := sessionruntime.NewManager(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ OwnerID: "abort-alignment-owner", OwnerLeaseTTL: time.Minute, @@ -301,7 +137,7 @@ func TestSpawnAbortAlignsManagerLedgerAndLifecycle(t *testing.T) { } func TestSpawnWatchdogRetryKeepsManagerRunActive(t *testing.T) { - runs := &abortAlignmentLedger{} + runs := newAbortAlignmentLedger() manager := sessionruntime.NewManager(sessionruntime.NewMemoryBackend(), sessionruntime.Options{ OwnerID: "watchdog-retry-owner", OwnerLeaseTTL: time.Minute, diff --git a/internal/agent/application/subagent_step_commit.go b/internal/agent/application/subagent_step_commit.go index 08092c3f9..9b2a897e4 100644 --- a/internal/agent/application/subagent_step_commit.go +++ b/internal/agent/application/subagent_step_commit.go @@ -70,6 +70,7 @@ func (s *Service) SubagentStepCommit( } committer := &subagentStepCommitter{ persister: persister, + ownerContext: ctx, runID: handle.RunID, botID: botID, sessionID: sessionID, @@ -86,11 +87,12 @@ func (s *Service) SubagentStepCommit( // carries no ChatRequest: the subagent path has no resolved chat context, and // its user message is persisted by the spawn provider before execution starts. type subagentStepCommitter struct { - persister messagepkg.AgentStepPersister - runID string - botID string - sessionID string - modelID string + ownerContext context.Context + persister messagepkg.AgentStepPersister + runID string + botID string + sessionID string + modelID string // turnRequestMessageID binds every step row to the task's persisted user // message, so the whole run files under the turn admission allocated // instead of splitting into history-minted turns. @@ -114,6 +116,11 @@ func (c *subagentStepCommitter) persist(ctx context.Context, stepIndex int, step if c == nil || step == nil { return errors.New("agent step is missing") } + persistCtx, ownershipErr := stepPersistenceContext(ctx, c.ownerContext) + if ownershipErr != nil { + return ownershipErr + } + ctx = persistCtx c.mu.Lock() defer c.mu.Unlock() if stepIndex != c.nextStep { diff --git a/internal/agent/application/turn_admission.go b/internal/agent/application/turn_admission.go index 27a2d33d1..3d8fb7f4d 100644 --- a/internal/agent/application/turn_admission.go +++ b/internal/agent/application/turn_admission.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" + userinput "github.com/felinics/memoh/internal/agent/decision/input" "github.com/felinics/memoh/internal/agent/runtime/native" sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" tools "github.com/felinics/memoh/internal/agent/tool" @@ -30,7 +31,7 @@ import ( // stub here would only assert that the stub agrees with itself. type turnAdmitter interface { Admit(context.Context, sessionruntime.AdmitInput) (sessionruntime.Admission, error) - FinishRun(ctx context.Context, handle sessionruntime.RunHandle, status, message string) error + FinishRunWithErrorCode(ctx context.Context, handle sessionruntime.RunHandle, status, errorCode string) error // MarkInlineDecisionRun declares an admitted run's decision semantics: // its runtime blocks inline on decisions, so terminal decision statuses // resume the run (external drivers). Native runs skip the declaration @@ -38,10 +39,6 @@ type turnAdmitter interface { MarkInlineDecisionRun(botID, sessionID, runID string) } -type codedTurnFinisher interface { - FinishRunWithErrorCode(ctx context.Context, handle sessionruntime.RunHandle, status, errorCode string) error -} - // SetSessionRuntime injects the durable admission gate. Setter injection rather // than a constructor argument because the manager and this service are wired // into the same fx graph and each is reachable from the other's dependencies. @@ -52,6 +49,7 @@ func (s *Service) SetSessionRuntime(manager *sessionruntime.Manager) { return } s.sessionRuntime = manager + s.sessionManager = manager s.decisionRuntime = manager s.abortRuntime = manager s.publishTurnEvent = func(ctx context.Context, handle sessionruntime.RunHandle, event native.StreamEvent) error { @@ -59,12 +57,48 @@ func (s *Service) SetSessionRuntime(manager *sessionruntime.Manager) { return err } manager.SetDecisionStore(s) + manager.SetLostRunDecisionCanceller(func(ctx context.Context, botID, sessionID, runID string, fencingToken int64, reason string) error { + canceller, ok := s.userInput.(interface { + CancelPendingForRun(context.Context, string, string, string, int64, string) ([]userinput.Request, error) + }) + if !ok { + return nil + } + _, err := canceller.CancelPendingForRun(ctx, botID, sessionID, runID, fencingToken, reason) + return err + }) manager.SetCommandHandler(s.handleRuntimeDecisionCommand) manager.SetDecisionFinalizer(s.finalizeRuntimeDecisions) - manager.SetTerminalObserver(s.reconcileTerminalContextLifecycle) + manager.SetTerminalObserver(func(ctx context.Context, terminal sessionruntime.TerminalRun) { + s.reconcileTerminalContextLifecycle(ctx, terminal) + // Steers die with their run; follow-ups outlive it. Close the steer + // queue before the follow-up starter so a continuation run never sees + // a stale steer that still names the finished run. + s.closeSteerQueueForRun(ctx, terminal) + s.startFollowUpAfterTerminal(ctx, terminal) + }) manager.SetTerminalReconciler(s.reconcileTerminalContextLifecycles) } +func drainDeferredTurn(handle turn.RunHandle) { + if handle == nil { + return + } + events, errs := handle.Events(), handle.Errs() + for events != nil || errs != nil { + select { + case _, ok := <-events: + if !ok { + events = nil + } + case _, ok := <-errs: + if !ok { + errs = nil + } + } + } +} + // admitTurnRun puts a StartTurnCommand through durable admission and answers in // the vocabulary of the turn port, which is the only agent surface its callers // can see. @@ -120,7 +154,7 @@ func (s *Service) admitTurnRun( // The same retry identity naming different content. Whichever side is // wrong, running it would double-answer a message that already has a // run, so it is dropped exactly like a redelivery. - return sessionruntime.Admission{}, fmt.Errorf("%w: %s conflicts with an earlier submission", turn.ErrDuplicateTurn, invocationID) + return sessionruntime.Admission{}, fmt.Errorf("%w: %s: %w", turn.ErrDuplicateTurn, invocationID, sessionruntime.ErrInvocationConflict) case err != nil: return sessionruntime.Admission{}, fmt.Errorf("admit turn: %w", err) } @@ -174,14 +208,7 @@ func (s *Service) turnRunFinisher(ctx context.Context, admission sessionruntime. errorCode := strings.TrimSpace(string(apperror.CodeOf(cause))) ctx, cancel := context.WithTimeout(writeCtx, terminalWriteTimeout) defer cancel() - var err error - if coded, ok := s.sessionRuntime.(codedTurnFinisher); ok && errorCode != "" { - err = coded.FinishRunWithErrorCode(ctx, handle, status, errorCode) - } else { - // Compatibility implementations still receive only stable codes; raw - // provider diagnostics never cross this terminal boundary. - err = s.sessionRuntime.FinishRun(ctx, handle, status, errorCode) - } + err := s.sessionRuntime.FinishRunWithErrorCode(ctx, handle, status, errorCode) switch { case err == nil: if !staged && (status != "" || cause != nil) { diff --git a/internal/agent/application/turn_admission_integration_test.go b/internal/agent/application/turn_admission_integration_test.go index 4785f1a07..eb07ae641 100644 --- a/internal/agent/application/turn_admission_integration_test.go +++ b/internal/agent/application/turn_admission_integration_test.go @@ -2,6 +2,7 @@ package application import ( "context" + "encoding/json" "log/slog" "os" "strings" @@ -15,6 +16,7 @@ import ( sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" "github.com/felinics/memoh/internal/agent/runtime/session/ledger" "github.com/felinics/memoh/internal/agent/turn" + chatview "github.com/felinics/memoh/internal/agent/view" dbpkg "github.com/felinics/memoh/internal/db" "github.com/felinics/memoh/internal/db/dbtest" dbsqlc "github.com/felinics/memoh/internal/db/postgres/sqlc" @@ -88,7 +90,17 @@ func TestAdmitTurnRunRequestUserTurnReachesSubscriber(t *testing.T) { if event.Snapshot == nil || event.Snapshot.CurrentRunView == nil { t.Fatal("snapshot has no current run, want the admitted run") } - request := event.Snapshot.CurrentRunView.RequestUserTurn + data, err := json.Marshal(event.Snapshot.CurrentRunView) + if err != nil { + t.Fatal(err) + } + var wire struct { + RequestUserTurn *chatview.UITurn `json:"request_user_turn"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + request := wire.RequestUserTurn if request == nil { t.Fatal("snapshot request_user_turn is nil — the #1044 regression shape") } diff --git a/internal/agent/application/turn_admission_test.go b/internal/agent/application/turn_admission_test.go index d9a6a851a..5270c05dd 100644 --- a/internal/agent/application/turn_admission_test.go +++ b/internal/agent/application/turn_admission_test.go @@ -33,7 +33,7 @@ func (f *fakeTurnAdmitter) Admit(_ context.Context, input sessionruntime.AdmitIn }, nil } -func (*fakeTurnAdmitter) FinishRun(context.Context, sessionruntime.RunHandle, string, string) error { +func (*fakeTurnAdmitter) FinishRunWithErrorCode(context.Context, sessionruntime.RunHandle, string, string) error { return nil } diff --git a/internal/agent/application/turn_inject_ownership_test.go b/internal/agent/application/turn_inject_ownership_test.go index 92c4d8bd8..3f2d9314b 100644 --- a/internal/agent/application/turn_inject_ownership_test.go +++ b/internal/agent/application/turn_inject_ownership_test.go @@ -37,7 +37,7 @@ func (a *injectOwnershipAdmitter) Admit(_ context.Context, input sessionruntime. }, nil } -func (a *injectOwnershipAdmitter) FinishRun(context.Context, sessionruntime.RunHandle, string, string) error { +func (a *injectOwnershipAdmitter) FinishRunWithErrorCode(context.Context, sessionruntime.RunHandle, string, string) error { close(a.finishStarted) <-a.finishRelease a.input.Execution.InjectCh <- turn.InjectMessage{Text: "finishing steer"} diff --git a/internal/agent/application/turn_service.go b/internal/agent/application/turn_service.go index 94baeb6fe..75f0ea8d4 100644 --- a/internal/agent/application/turn_service.go +++ b/internal/agent/application/turn_service.go @@ -56,6 +56,11 @@ func (s *Service) StartTurn(ctx context.Context, cmd turn.StartTurnCommand) (tur } injectCh := make(chan turn.InjectMessage, 16) + // A busy thread is reported as ErrSessionBusy. Whether to park the command + // in the follow-up queue is the ingress's decision (EnqueueDeferredTurn): + // only a caller whose user sees the run through the session runtime + // subscription can drop its handle, because a run started from the queue + // has no other consumer for its output. admission, err := s.admitTurnRun(runCtx, cmd, cancel, cancelCause, injectCh) if err != nil { cancel() @@ -70,9 +75,11 @@ func (s *Service) StartTurn(ctx context.Context, cmd turn.StartTurnCommand) (tur req := chatRequestFromCommand(cmd) req.RunID = admission.RunID + req.RunHandle = admission.Handle req.TurnID = admission.TurnID req.TurnPosition = &admission.TurnPosition req.InjectCh = injectCh + req.QueueSteerEnabled = injectCh != nil req.OutboundAssetCollector = func() []turn.OutboundAssetRef { assetMu.Lock() defer assetMu.Unlock() diff --git a/internal/agent/application/turn_service_test.go b/internal/agent/application/turn_service_test.go index d1358927a..37ee5f394 100644 --- a/internal/agent/application/turn_service_test.go +++ b/internal/agent/application/turn_service_test.go @@ -88,7 +88,7 @@ func (a *scriptedAdmitter) Admit(_ context.Context, in sessionruntime.AdmitInput }, nil } -func (a *scriptedAdmitter) FinishRun(_ context.Context, handle sessionruntime.RunHandle, status, message string) error { +func (a *scriptedAdmitter) FinishRunWithErrorCode(_ context.Context, handle sessionruntime.RunHandle, status, message string) error { a.mu.Lock() defer a.mu.Unlock() a.finishes = append(a.finishes, recordedFinish{handle: handle, status: status, message: message}) diff --git a/internal/agent/decision/input/interaction_test.go b/internal/agent/decision/input/interaction_test.go index aa63cbda3..b9a225fa6 100644 --- a/internal/agent/decision/input/interaction_test.go +++ b/internal/agent/decision/input/interaction_test.go @@ -1,6 +1,12 @@ package input -import "testing" +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) func interactionTestPayload() UIPayload { return UIPayload{ @@ -104,3 +110,52 @@ func TestApplyInteractionOpSameSelectionTogglesOffWithoutAdvance(t *testing.T) { t.Fatalf("re-select must clear: %#v", state) } } + +func TestServiceAdvanceInteractionDoesNotRequireRuntimeFence(t *testing.T) { + t.Parallel() + + queries := newFakeUserInputQueries() + svc := NewService(nil, queries) + future := time.Now().Add(time.Hour) + req := createStorePending(t, svc, &future, "telegram-button") + queries.mu.Lock() + row := queries.rows[req.ID] + row.RuntimeFencingToken = pgtype.Int8{Int64: 42, Valid: true} + queries.mu.Unlock() + + result, err := svc.AdvanceInteraction(context.Background(), AdvanceInteractionInput{ + BotID: storeTestBotID, + RequestID: req.ID, + Op: InteractionOp{Kind: OpSelectOption, QuestionIndex: 0, OptionIndex: 0}, + }) + if err != nil { + t.Fatalf("advance interaction: %v", err) + } + if !result.Handled || !result.Changed { + t.Fatalf("result = %#v, want handled changed", result) + } + if answer, ok := result.Request.Interaction.Answer("q1"); !ok || len(answer.OptionIDs) != 1 { + t.Fatalf("interaction = %#v, want q1 selection", result.Request.Interaction) + } +} + +func TestServiceAdvanceInteractionTreatsExpiredRequestAsUnhandled(t *testing.T) { + t.Parallel() + + queries := newFakeUserInputQueries() + svc := NewService(nil, queries) + expired := time.Now().Add(-time.Minute) + req := createStorePending(t, svc, &expired, "telegram-button-expired") + + result, err := svc.AdvanceInteraction(context.Background(), AdvanceInteractionInput{ + BotID: storeTestBotID, + RequestID: req.ID, + Op: InteractionOp{Kind: OpSelectOption, QuestionIndex: 0, OptionIndex: 0}, + }) + if err != nil { + t.Fatalf("advance expired interaction: %v", err) + } + if result.Handled { + t.Fatalf("expired result = %#v, want unhandled", result) + } +} diff --git a/internal/agent/decision/input/service.go b/internal/agent/decision/input/service.go index 7e4b04675..d595b6008 100644 --- a/internal/agent/decision/input/service.go +++ b/internal/agent/decision/input/service.go @@ -459,6 +459,47 @@ func (s *Service) CancelPendingForSession(ctx context.Context, botID, sessionID, return requests, nil } +// CancelPendingForRun invalidates only pending ask_user requests owned by one +// exact run. It is used by runtime recovery after a run is declared lost; +// session-wide cancellation would incorrectly expire a newer run's request. +func (s *Service) CancelPendingForRun(ctx context.Context, botID, sessionID, runID string, fencingToken int64, reason string) ([]Request, error) { + if s == nil || s.queries == nil { + return nil, errors.New("user input queries not configured") + } + pgBotID, err := db.ParseUUID(botID) + if err != nil { + return nil, err + } + pgSessionID, err := db.ParseUUID(sessionID) + if err != nil { + return nil, err + } + pgRunID, err := db.ParseUUID(runID) + if err != nil { + return nil, err + } + resultJSON, err := json.Marshal(canceledResult(reason)) + if err != nil { + return nil, err + } + params := sqlc.CancelPendingUserInputsByRunParams{ + BotID: pgBotID, SessionID: pgSessionID, RunID: pgRunID, + ResultJson: resultJSON, + RuntimeFencingToken: pgtype.Int8{Int64: fencingToken, Valid: fencingToken > 0}, + } + rows, err := s.queries.CancelPendingUserInputsByRun(ctx, params) + if err != nil { + return nil, err + } + requests := make([]Request, 0, len(rows)) + for _, row := range rows { + req := requestFromRow(row) + requests = append(requests, req) + s.notifyResolved(req) + } + return requests, nil +} + func (s *Service) Fail(ctx context.Context, requestID string, result map[string]any) (Request, error) { if s == nil || s.queries == nil { return Request{}, errors.New("user input queries not configured") diff --git a/internal/agent/decision/input/service_store_test.go b/internal/agent/decision/input/service_store_test.go index 66dceb216..d952a0b61 100644 --- a/internal/agent/decision/input/service_store_test.go +++ b/internal/agent/decision/input/service_store_test.go @@ -160,6 +160,16 @@ func (q *fakeUserInputQueries) GetUserInputRequest(_ context.Context, id pgtype. return *row, nil } +func (q *fakeUserInputQueries) GetInteractiveUserInputRequest(_ context.Context, arg sqlc.GetInteractiveUserInputRequestParams) (sqlc.UserInputRequest, error) { + q.mu.Lock() + defer q.mu.Unlock() + row, ok := q.rows[storeUUIDKey(arg.ID)] + if !ok || !storeRowIsLivePending(row, time.Now()) || row.BotID != arg.BotID { + return sqlc.UserInputRequest{}, pgx.ErrNoRows + } + return *row, nil +} + func (q *fakeUserInputQueries) GetRespondableUserInputRequest(_ context.Context, arg sqlc.GetRespondableUserInputRequestParams) (sqlc.UserInputRequest, error) { q.mu.Lock() defer q.mu.Unlock() diff --git a/internal/agent/event/event.go b/internal/agent/event/event.go index 2e06289ab..a553cbf50 100644 --- a/internal/agent/event/event.go +++ b/internal/agent/event/event.go @@ -34,6 +34,12 @@ const ( // in the conversation (tools unavailable, an interaction declined). // Code carries the machine-readable reason, Delta the human text. RuntimeNotice StreamEventType = "runtime_notice" + // StepEnd marks that every streamed part of one model step has been + // emitted. StepNumber carries the durable step index. It is a sequencing + // marker for the session runtime, carries no visible content, and is + // emitted before the step's commit barrier runs, so a consumer that has + // seen it holds the complete pre-commit projection of that step. + StepEnd StreamEventType = "step_end" ) // StreamEvent is emitted by an agent runtime during streaming. The JSON diff --git a/internal/agent/runtime/acp/client/client_test.go b/internal/agent/runtime/acp/client/client_test.go index 4b11c432e..94c83a6fe 100644 --- a/internal/agent/runtime/acp/client/client_test.go +++ b/internal/agent/runtime/acp/client/client_test.go @@ -28,6 +28,7 @@ import ( "github.com/felinics/memoh/internal/config" "github.com/felinics/memoh/internal/mcp" "github.com/felinics/memoh/internal/runtimefence" + sessiontest "github.com/felinics/memoh/internal/testutil/sessionruntime" "github.com/felinics/memoh/internal/workspace/bridge" pb "github.com/felinics/memoh/internal/workspace/bridgepb" "github.com/felinics/memoh/internal/workspace/bridgesvc" @@ -2500,7 +2501,7 @@ func TestACPWorkspaceEffectsRejectStaleRedisOwner(t *testing.T) { t.Fatalf("create stale owner backend: %v", err) } t.Cleanup(func() { _ = rawOwnerBackend.Close() }) - owner := sessionruntime.NewManager(nonClosingDistributedBackend{DistributedBackend: rawOwnerBackend}, sessionruntime.Options{ + owner := sessiontest.New(nonClosingDistributedBackend{DistributedBackend: rawOwnerBackend, LivenessBackend: rawOwnerBackend}, sessionruntime.Options{ OwnerID: "acp-workspace-owner-a", StateTTL: time.Minute, OwnerLeaseTTL: 100 * time.Millisecond, }) if err := owner.Start(ctx); err != nil { @@ -2510,7 +2511,7 @@ func TestACPWorkspaceEffectsRejectStaleRedisOwner(t *testing.T) { if err != nil { t.Fatalf("create takeover backend: %v", err) } - takeover := sessionruntime.NewManager(backendB, sessionruntime.Options{ + takeover := sessiontest.New(backendB, sessionruntime.Options{ OwnerID: "acp-workspace-owner-b", StateTTL: time.Minute, OwnerLeaseTTL: 100 * time.Millisecond, }) if err := takeover.Start(ctx); err != nil { @@ -2524,7 +2525,7 @@ func TestACPWorkspaceEffectsRejectStaleRedisOwner(t *testing.T) { streamA = "stream-acp-workspace-owner-a" streamB = "stream-acp-workspace-owner-b" ) - ownerHandle, err := owner.StartRunHandle(ctx, botID, sessionID, streamA, make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)) + ownerHandle, err := sessiontest.Start(ctx, owner, botID, sessionID, streamA, make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)) if err != nil { t.Fatalf("start stale owner run: %v", err) } @@ -2549,7 +2550,7 @@ func TestACPWorkspaceEffectsRejectStaleRedisOwner(t *testing.T) { if _, err := takeover.Snapshot(ctx, botID, sessionID); err != nil { t.Fatalf("reconcile expired owner: %v", err) } - if err := takeover.StartRun(ctx, botID, sessionID, streamB, make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)); err != nil { + if _, err := sessiontest.Start(ctx, takeover, botID, sessionID, streamB, make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)); err != nil { t.Fatalf("start takeover run: %v", err) } @@ -2590,6 +2591,7 @@ func TestACPWorkspaceEffectsRejectStaleRedisOwner(t *testing.T) { } type nonClosingDistributedBackend struct { + sessionruntime.LivenessBackend sessionruntime.DistributedBackend } diff --git a/internal/agent/runtime/native/agent.go b/internal/agent/runtime/native/agent.go index 3156246ed..cddfe3377 100644 --- a/internal/agent/runtime/native/agent.go +++ b/internal/agent/runtime/native/agent.go @@ -281,19 +281,28 @@ func sendEvent(ctx context.Context, ch chan<- StreamEvent, evt StreamEvent) bool } func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEvent) { - cfg.Model = modelWithProviderStreamEventObserver(cfg.Model, cfg.OnProviderStreamEventObserved) if cfg.ContextLifecycle == nil { cfg.ContextLifecycle = contextfrag.NewLifecycleHolder() } streamCtx, cancel := context.WithCancelCause(ctx) + var steerGate *modelSteerGate + if cfg.PendingSteer != nil && cfg.OnSteer != nil { + steerGate = &modelSteerGate{cancel: cancel, ready: make(chan struct{}, 1)} + go a.watchSteer(streamCtx, cfg, steerGate) + } + cfg.Model = modelWithProviderStreamEventObserver(cfg.Model, cfg.OnProviderStreamEventObserved, steerGate) eventGate := newStreamEmitterGate(streamCtx, ch) defer func() { cancel(nil) eventGate.close() }() aborted := false + continued := false turnError := "" defer func() { + if continued { + return + } event := hooks.EventTurnEnd if aborted || strings.TrimSpace(turnError) != "" { event = hooks.EventTurnError @@ -442,7 +451,9 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv } } + prepareStep = prepareQueuedSteer(prepareStep, cfg) prepareStep, committedStepMessages := capturePreparedStepMessages(prepareStep) + committedStepMessages.byStep[0] = cloneProviderMessages(cfg.initialStepInputs) if readMediaState != nil { committedStepMessages.addAdmissionObserver(readMediaState.reconcilePreparedMessages) } @@ -469,9 +480,12 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv } opts = append(opts, a.onStepOption(streamCtx, cfg, nil)) var nextDurableStep int + // emittedStep counts FinishStepParts seen on this attempt so the step_end + // marker can name the durable step index the following commit will use. + emittedStep := 0 onStepCommitted := func(ctx context.Context, stepIndex int, step *sdk.StepResult) error { if cfg.OnStepCommitted != nil { - if err := cfg.OnStepCommitted(ctx, stepIndex, committedStepMessages.decorate(stepIndex, step, toolExecutionMetadata)); err != nil { + if err := cfg.OnStepCommitted(ctx, cfg.StepIndexOffset+stepIndex, committedStepMessages.decorate(stepIndex, step, toolExecutionMetadata)); err != nil { return err } } @@ -525,7 +539,9 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv } } - sendEvent(ctx, ch, StreamEvent{Type: EventAgentStart}) + if !cfg.SuppressAgentStart { + sendEvent(ctx, ch, StreamEvent{Type: EventAgentStart}) + } var allText strings.Builder var interruptedStep interruptedStepCapture @@ -553,6 +569,15 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv case *sdk.StartPart: _ = p // stream start already emitted + case *sdk.FinishStepPart: + // Emitted after every part of the step and before the SDK invokes the + // commit barrier. The session runtime uses it to know the step's live + // projection is complete before it anchors a queue steer to it. + if !sendEvent(ctx, ch, StreamEvent{Type: EventStepEnd, StepNumber: cfg.StepIndexOffset + emittedStep}) { + aborted = true + } + emittedStep++ + case *sdk.TextStartPart: if !sendEvent(ctx, ch, StreamEvent{Type: EventTextStart}) { aborted = true @@ -725,7 +750,7 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv } case *sdk.ErrorPart: - if contextStepBudgetError(streamCtx) != nil { + if streamCtx.Err() != nil { aborted = true break } @@ -765,7 +790,8 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv break } } - if ctx.Err() != nil { + steered := errors.Is(context.Cause(streamCtx), errModelSteered) + if ctx.Err() != nil || steered { aborted = true } @@ -785,6 +811,45 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv streamClosed = drainStreamUntilClosed(streamResult.Stream, streamCancelDrainGrace, interruptedStep.observe) } + if steered && ctx.Err() == nil && streamClosed { + // The SDK is now quiescent: it cannot commit or execute a late tool. + // Checkpoint even a silent attempt so the original user/input admission + // and step cursor survive before we append the next input to this run. + step := interruptedStep.snapshot(nextDurableStep) + if step == nil { + step = &sdk.StepResult{} + } + step = committedStepMessages.decorate(nextDurableStep, step, toolExecutionMetadata) + checkpointMessages := step.Messages + if nextDurableStep == 0 { + // Initial steer inputs already live in cfg.Messages. The decorated + // step includes them for persistence, not a second prompt insertion. + checkpointMessages = checkpointMessages[min(len(cfg.initialStepInputs), len(checkpointMessages)):] + } + index := cfg.StepIndexOffset + nextDurableStep + sendEvent(ctx, ch, StreamEvent{Type: EventStepEnd, StepNumber: index}) + if err := cfg.OnSteer(ctx, index, step); err == nil { + messages := append(steerContinuationMessages(cfg, streamResult.Steps, committedStepMessages), steerCheckpointMessages(checkpointMessages)...) + cfg = appendSteerContinuation(cfg, messages, nextDurableStep+1) + if cfg.ContinueAfterFinal != nil { + cfg.ContinueAfterFinal.Store(false) + } + eventGate.close() + continued = true + a.runStream(ctx, cfg, ch) + return + } else { + a.logger.Error("checkpoint steered model invocation failed", slog.Any("error", err)) + } + } + if steered && ctx.Err() == nil { + // An unquiesced invocation or failed checkpoint cannot safely resume. + // Use the existing public interruption error; diagnostics stay in logs. + public, _ := apperror.PublicFrom(apperror.New(apperror.CodeAgentResponseInterrupted, nil), "") + turnError = public.Detail + sendEvent(ctx, ch, StreamEvent{Type: EventError, Error: public.Detail, Code: string(public.Code)}) + } + // Only external cancellation can represent a user/session abort. Provider // errors and loop guards keep their existing failure semantics. // @@ -799,7 +864,7 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv stepIndex := nextDurableStep if step := interruptedStep.snapshot(stepIndex); step != nil { step = committedStepMessages.decorate(stepIndex, step, toolExecutionMetadata) - if err := cfg.OnStepInterrupted(context.WithoutCancel(streamCtx), stepIndex, step); err != nil { + if err := cfg.OnStepInterrupted(ctx, cfg.StepIndexOffset+stepIndex, step); err != nil { // An owner that lost its lease, or a run another writer already // finalized, is an expected outcome of racing an abort. a.logger.Warn("persist interrupted model step failed", slog.Any("error", err)) @@ -875,6 +940,21 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv cfg.InjectedRecorder, ) } + // A final response can still discover a steer item at the commit + // boundary. Re-open the same run with the committed transcript so the + // steer becomes the next model input instead of being stranded after the + // terminal event. + if streamClosed && !aborted && streamResult != nil && cfg.ContinueAfterFinal != nil && cfg.ContinueAfterFinal.Swap(false) { + cfg = appendSteerContinuation(cfg, steerContinuationMessages(cfg, streamResult.Steps, committedStepMessages), len(streamResult.Steps)) + // The completed invocation must stop its emitters before the continuation + // starts; the continuation gets a fresh child context from the original + // run context so closing the old stream does not cancel it. + cancel(context.Canceled) + eventGate.close() + continued = true + a.runStream(ctx, cfg, ch) + return + } // Stop secondary producers before delivering the terminal event. The stream // context cancellation also unblocks an emitter already waiting on ch. cancel(context.Canceled) @@ -1026,7 +1106,9 @@ func (a *Agent) runGenerate(ctx context.Context, cfg RunConfig) (result *Generat prepareStep = readMediaState.prepareStep } + prepareStep = prepareQueuedSteer(prepareStep, cfg) prepareStep, committedStepMessages := capturePreparedStepMessages(prepareStep) + committedStepMessages.byStep[0] = cloneProviderMessages(cfg.initialStepInputs) if readMediaState != nil { committedStepMessages.addAdmissionObserver(readMediaState.reconcilePreparedMessages) } @@ -1063,7 +1145,7 @@ func (a *Agent) runGenerate(ctx context.Context, cfg RunConfig) (result *Generat ) if cfg.OnStepCommitted != nil { opts = append(opts, sdk.WithOnStepCommitted(func(ctx context.Context, stepIndex int, step *sdk.StepResult) error { - return cfg.OnStepCommitted(ctx, stepIndex, committedStepMessages.decorate(stepIndex, step, toolExecutionMetadata)) + return cfg.OnStepCommitted(ctx, cfg.StepIndexOffset+stepIndex, committedStepMessages.decorate(stepIndex, step, toolExecutionMetadata)) })) } @@ -1111,6 +1193,16 @@ func (a *Agent) runGenerate(ctx context.Context, cfg RunConfig) (result *Generat finalMessages = readMediaState.mergeMessages(genResult.Steps, finalMessages, -1) } finalMessages = toolExecutionMetadata.annotate(finalMessages) + if cfg.ContinueAfterFinal != nil && cfg.ContinueAfterFinal.Swap(false) && len(genResult.Steps) > 0 { + cfg = appendSteerContinuation(cfg, steerContinuationMessages(cfg, genResult.Steps, committedStepMessages), len(genResult.Steps)) + next, nextErr := a.runGenerate(genCtx, cfg) + if nextErr != nil { + return nil, nextErr + } + next.Messages = append(finalMessages, next.Messages...) + next.Text = strings.TrimSpace(strings.Join([]string{genResult.Text, next.Text}, "\n")) + return next, nil + } return &GenerateResult{ Messages: finalMessages, Text: genResult.Text, @@ -1121,6 +1213,55 @@ func (a *Agent) runGenerate(ctx context.Context, cfg RunConfig) (result *Generat }, nil } +// appendSteerContinuation advances a completed invocation without changing its +// run identity or replaying its start event. Both execution modes share this +// input/step transition; their output delivery and finalizers remain separate. +func appendSteerContinuation(cfg RunConfig, messages []sdk.Message, steps int) RunConfig { + appended := append([]sdk.Message(nil), messages...) + cfg.initialStepInputs = nil + if cfg.NextModelInputs != nil { + cfg.initialStepInputs = cloneProviderMessages(*cfg.NextModelInputs) + appended = append(appended, cfg.initialStepInputs...) + *cfg.NextModelInputs = nil + } + cfg.Messages = append(append([]sdk.Message(nil), cfg.Messages...), appended...) + cfg.StepIndexOffset += steps + cfg.SuppressAgentStart = true + if len(cfg.initialStepInputs) > 0 { + current := len(cfg.Messages) - 1 + cfg.ContextCurrentUserMessageIndex = ¤t + } + if len(cfg.ContextSourceFrags) > 0 { + // The production applier renders typed sources, not cfg.Messages. + // Continue from the already-selected context and append this invocation's + // new messages, keeping system/workspace provenance intact. + frags := append([]contextfrag.ContextFrag(nil), cfg.ContextFrags...) + if len(frags) == 0 { + frags = append(frags, cfg.ContextSourceFrags...) + } + lastIndex := -1 + for i := range frags { + if frags[i].Provenance.Index > lastIndex { + lastIndex = frags[i].Provenance.Index + } + if frags[i].Kind == contextfrag.KindCurrentUserMessage { + frags[i].Kind = contextfrag.KindConversationEvent + frags[i].Slot = contextfrag.SlotHistory + } + } + current := len(appended) - 1 + added := contextfrag.CompileFrags(contextfrag.CompileInput{Scope: cfg.ContextScope, Messages: appended, CurrentUserMessageIndex: ¤t}) + for i := range added { + added[i].ID = fmt.Sprintf("continuation.%d.%03d", cfg.StepIndexOffset, i) + added[i].Provenance.Index = lastIndex + 1 + i + added[i].Budget.Overflow = contextfrag.OverflowKeep + } + frags = append(frags, added...) + cfg.ContextSourceFrags = frags + } + return cfg +} + func (a *Agent) buildGenerateOptions(ctx context.Context, cfg RunConfig, tools []sdk.Tool, approvalTools []sdk.Tool, prepareStep func(*sdk.GenerateParams) *sdk.GenerateParams) []sdk.GenerateOption { handoff := newProviderAttemptHandoff(cfg) tools = canonicalizeProviderToolSchemas(tools) diff --git a/internal/agent/runtime/native/provider_stream_observer.go b/internal/agent/runtime/native/provider_stream_observer.go index 940e2be29..9523cb629 100644 --- a/internal/agent/runtime/native/provider_stream_observer.go +++ b/internal/agent/runtime/native/provider_stream_observer.go @@ -9,14 +9,25 @@ import ( type providerStreamEventObserver struct { sdk.Provider observe func(StreamEvent) + steer *modelSteerGate } -func modelWithProviderStreamEventObserver(model *sdk.Model, observe func(StreamEvent)) *sdk.Model { - if model == nil || model.Provider == nil || observe == nil { +func modelWithProviderStreamEventObserver(model *sdk.Model, observe func(StreamEvent), steer *modelSteerGate) *sdk.Model { + if model == nil || model.Provider == nil || (observe == nil && steer == nil) { return model } observed := *model - observed.Provider = providerStreamEventObserver{Provider: model.Provider, observe: observe} + provider := model.Provider + // A final-steer continuation reuses the model from the preceding call. + // Replace our observer instead of nesting another stream/notification loop. + for { + previous, ok := provider.(providerStreamEventObserver) + if !ok { + break + } + provider = previous.Provider + } + observed.Provider = providerStreamEventObserver{Provider: provider, observe: observe, steer: steer} return &observed } @@ -24,7 +35,10 @@ func (p providerStreamEventObserver) DoStream(ctx context.Context, params sdk.Ge // Every provider call begins a fresh attempt. For ordinary multi-step runs // the previous step has already consumed or checkpointed its timings; for a // retry this discards the failed attempt before replacement parts arrive. - p.observe(StreamEvent{Type: EventRetry}) + if p.observe != nil { + p.observe(StreamEvent{Type: EventRetry}) + } + p.steer.begin() result, err := p.Provider.DoStream(ctx, params) if err != nil || result == nil || result.Stream == nil { return result, err @@ -46,7 +60,10 @@ func (p providerStreamEventObserver) DoStream(ctx context.Context, params sdk.Ge case <-ctx.Done(): return } - if event, ok := providerPartTimingEvent(part); ok { + if !p.steer.observe(part) { + return + } + if event, ok := providerPartTimingEvent(part); ok && p.observe != nil { p.observe(event) } select { diff --git a/internal/agent/runtime/native/steer.go b/internal/agent/runtime/native/steer.go new file mode 100644 index 000000000..14738391c --- /dev/null +++ b/internal/agent/runtime/native/steer.go @@ -0,0 +1,159 @@ +package native + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + sdk "github.com/felinics/twilight/sdk" + + contextfrag "github.com/felinics/memoh/internal/agent/context/fragment" +) + +var errModelSteered = errors.New("model invocation steered") + +// The commit callback and PrepareStep run serially on the SDK loop. Reading +// here avoids a sender/forwarder race with an immediately following tool step. +func prepareQueuedSteer(prepare func(*sdk.GenerateParams) *sdk.GenerateParams, cfg RunConfig) func(*sdk.GenerateParams) *sdk.GenerateParams { + if cfg.NextModelInputs == nil { + return prepare + } + return func(params *sdk.GenerateParams) *sdk.GenerateParams { + if prepare != nil { + if override := prepare(params); override != nil { + params = override + } + } + if len(*cfg.NextModelInputs) > 0 { + cfg.ContextMutations.Record(contextfrag.MutationInjectedMessage, fmt.Sprintf("messages=%d", len(*cfg.NextModelInputs))) + params.Messages = append(params.Messages, *cfg.NextModelInputs...) + *cfg.NextModelInputs = nil + } + return params + } +} + +// modelSteerGate serializes interruption with provider output BEFORE the SDK +// can execute tools or commit a completed step. Consumer-side stream flags are +// too late: provider events may already be buffered ahead of the UI consumer. +type modelSteerGate struct { + mu sync.Mutex + sampling bool + stopped bool + cancel context.CancelCauseFunc + ready chan struct{} +} + +// Notifications must remain live while the main consumer is inside retry +// handling as well as its normal stream loop. This worker owns no input or +// history state and stops with this invocation's context. +func (a *Agent) watchSteer(ctx context.Context, cfg RunConfig, gate *modelSteerGate) { + for { + select { + case <-ctx.Done(): + return + case <-cfg.SteerWake: + case <-gate.ready: + } + pending, err := cfg.PendingSteer(ctx) + if err != nil { + if ctx.Err() == nil { + a.logger.Warn("check pending steer failed", slog.Any("error", err)) + } + continue + } + if pending && gate.interrupt() { + return + } + } +} + +// SDK output omits inputs inserted by PrepareStep. Rebuild the committed +// transcript with their admitted provenance so a later steer cannot forget +// an earlier steer/read_media input. Initial inputs already live in cfg.Messages. +func steerContinuationMessages(cfg RunConfig, steps []sdk.StepResult, capture *stepMessageCapture) []sdk.Message { + var messages []sdk.Message + for i, step := range steps { + inputs := capture.messages(i) + if i == 0 { + inputs = inputs[min(len(cfg.initialStepInputs), len(inputs)):] + } + messages = append(messages, inputs...) + messages = append(messages, step.Messages...) + } + return messages +} + +func (g *modelSteerGate) begin() { + if g == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.sampling = !g.stopped + select { + case g.ready <- struct{}{}: + default: + } +} + +func (g *modelSteerGate) observe(part sdk.StreamPart) bool { + if g == nil { + return true + } + g.mu.Lock() + defer g.mu.Unlock() + if g.stopped { + return false + } + switch part.(type) { + case *sdk.ToolInputStartPart, *sdk.ToolInputDeltaPart, *sdk.ToolInputEndPart, + *sdk.StreamToolCallPart, *sdk.FinishStepPart, *sdk.ErrorPart, *sdk.AbortPart: + g.sampling = false + } + return true +} + +func (g *modelSteerGate) interrupt() bool { + if g == nil { + return false + } + g.mu.Lock() + defer g.mu.Unlock() + if !g.sampling || g.stopped { + return false + } + g.stopped = true + g.cancel(errModelSteered) + return true +} + +// An unfinished reasoning block can lack the provider's final signature. +// Keep its text as a checkpoint, never replay opaque/incomplete reasoning. +func steerCheckpointMessages(messages []sdk.Message) []sdk.Message { + result := make([]sdk.Message, 0, len(messages)) + for _, message := range cloneProviderMessages(messages) { + var parts []sdk.MessagePart + for _, part := range message.Content { + switch p := part.(type) { + case sdk.ReasoningPart: + if p.Text != "" { + parts = append(parts, sdk.TextPart{Text: "[Interrupted reasoning checkpoint]\n" + p.Text}) + } + case *sdk.ReasoningPart: + if p.Text != "" { + parts = append(parts, sdk.TextPart{Text: "[Interrupted reasoning checkpoint]\n" + p.Text}) + } + default: + parts = append(parts, part) + } + } + if len(parts) > 0 { + message.Content = parts + result = append(result, message) + } + } + return result +} diff --git a/internal/agent/runtime/native/steer_test.go b/internal/agent/runtime/native/steer_test.go new file mode 100644 index 000000000..dd290df49 --- /dev/null +++ b/internal/agent/runtime/native/steer_test.go @@ -0,0 +1,286 @@ +package native + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + sdk "github.com/felinics/twilight/sdk" + "github.com/google/jsonschema-go/jsonschema" + + agenttools "github.com/felinics/memoh/internal/agent/tool" +) + +func TestStreamSteerInterruptsOnlyInvocation(t *testing.T) { + for _, mode := range []string{"text", "reasoning", "headers", "consecutive", "retry", "checkpoint_failure"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + wake := make(chan struct{}, 1) + started := make(chan int, 3) + var calls, disconnected atomic.Int32 + var pending, continueAfter atomic.Bool + var nextInputs []sdk.Message + var checkpoints, starts, terminals int + var steps []int + var finalInput []sdk.Message + interruptions := 1 + retryAttempts := 0 + if mode == "retry" { + retryAttempts = 1 + } + if mode == "consecutive" { + interruptions = 2 + } + provider := agentStreamTestProvider(func(ctx context.Context, params sdk.GenerateParams) (*sdk.StreamResult, error) { + call := int(calls.Add(1)) + if mode == "retry" && call == 1 { + return closedAgentTestStream(&sdk.ErrorPart{Error: errors.New("unexpected EOF")}), nil + } + call -= retryAttempts + if call > interruptions { + finalInput = cloneProviderMessages(params.Messages) + return closedAgentTestStream(&sdk.TextDeltaPart{Text: "done"}, &sdk.FinishStepPart{FinishReason: sdk.FinishReasonStop}), nil + } + if mode == "headers" { + started <- call + <-ctx.Done() + disconnected.Add(1) + return nil, ctx.Err() + } + parts := make(chan sdk.StreamPart) + go func() { + defer close(parts) + var part sdk.StreamPart = &sdk.TextDeltaPart{Text: fmt.Sprintf("partial-%d", call)} + if mode == "reasoning" { + part = &sdk.ReasoningDeltaPart{Text: "unfinished thinking"} + } + select { + case parts <- part: + case <-ctx.Done(): + } + <-ctx.Done() + disconnected.Add(1) + }() + return &sdk.StreamResult{Stream: parts}, nil + }) + events := New(Deps{}).Stream(ctx, RunConfig{ + Model: &sdk.Model{ID: "mock", Provider: provider}, Messages: []sdk.Message{sdk.UserMessage("original")}, + SteerWake: wake, PendingSteer: func(context.Context) (bool, error) { return pending.Load(), nil }, + ContinueAfterFinal: &continueAfter, NextModelInputs: &nextInputs, + OnSteer: func(_ context.Context, index int, _ *sdk.StepResult) error { + if index != checkpoints { + return fmt.Errorf("step %d, want %d", index, checkpoints) + } + checkpoints++ + if mode == "checkpoint_failure" { + return errors.New("SECRET database diagnostic") + } + pending.Store(false) + nextInputs = []sdk.Message{sdk.UserMessage(fmt.Sprintf("steer-%d", checkpoints))} + continueAfter.Store(true) + return nil + }, + }) + for events != nil { + select { + case <-started: + pending.Store(true) + wake <- struct{}{} + case e, ok := <-events: + if !ok { + events = nil + continue + } + if e.Type == EventTextDelta && strings.HasPrefix(e.Delta, "partial-") || e.Type == EventReasoningDelta { + pending.Store(true) + wake <- struct{}{} + } + if e.Type == EventAgentStart { + starts++ + } + if e.Type == EventStepEnd { + steps = append(steps, e.StepNumber) + } + if strings.Contains(e.Error, "SECRET") || (e.Type == EventError && mode != "checkpoint_failure" && mode != "retry") { + t.Fatalf("unexpected public error: %+v", e) + } + if e.IsTerminal() { + terminals++ + if mode != "checkpoint_failure" && e.Type != EventAgentEnd { + t.Fatalf("steer terminated run: %+v", e) + } + } + case <-ctx.Done(): + t.Fatal("steer failed to continue the blocked invocation") + } + } + if mode == "checkpoint_failure" { + if calls.Load() != 1 { + t.Fatal("continued after failed persistence") + } + return + } + if calls.Load() != int32(interruptions+retryAttempts+1) || disconnected.Load() != int32(interruptions) || starts != 1 || terminals != 1 { + t.Fatalf("calls=%d disconnected=%d starts=%d terminals=%d", calls.Load(), disconnected.Load(), starts, terminals) + } + for i, step := range steps { + if step != i { + t.Fatalf("step cursor: %v", steps) + } + } + var transcript strings.Builder + for _, message := range finalInput { + transcript.WriteString(messageContentText(message)) + for _, part := range message.Content { + if _, ok := part.(sdk.ReasoningPart); ok { + t.Fatal("replayed unfinished provider reasoning") + } + } + } + text := transcript.String() + if !strings.Contains(text, "original") || strings.Count(text, "steer-1") != 1 || mode == "consecutive" && strings.Count(text, "steer-2") != 1 { + t.Fatalf("continuation input: %q", text) + } + }) + } +} + +func TestSteerGatePreservesToolAndCommitBoundaries(t *testing.T) { + for _, part := range []sdk.StreamPart{&sdk.ToolInputStartPart{}, &sdk.StreamToolCallPart{}, &sdk.FinishStepPart{}} { + t.Run(fmt.Sprintf("%T", part), func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + g := &modelSteerGate{cancel: cancel, ready: make(chan struct{}, 1)} + g.begin() + g.observe(part) + if g.interrupt() || ctx.Err() != nil { + t.Fatal("interrupted tool/commit boundary") + } + g.begin() + if !g.interrupt() || !errors.Is(context.Cause(ctx), errModelSteered) || g.observe(&sdk.FinishStepPart{}) { + t.Fatal("next model invocation failed to fence late output") + } + }) + } +} + +func TestSteerPreservesToolsAndEarlierInput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + started := make(chan context.Context, 1) + release := make(chan struct{}) + wake := make(chan struct{}, 1) + var calls, executions atomic.Int32 + var pending atomic.Bool + var immediateInput atomic.Bool + var nextInputs []sdk.Message + var finalInput []sdk.Message + provider := agentStreamTestProvider(func(ctx context.Context, params sdk.GenerateParams) (*sdk.StreamResult, error) { + call := calls.Add(1) + if call <= 2 { + if call == 2 { + for _, message := range params.Messages { + if messageContentText(message) == "change direction" { + immediateInput.Store(true) + } + } + } + return closedAgentTestStream( + &sdk.StreamToolCallPart{ToolCallID: fmt.Sprintf("call-%d", call), ToolName: "held_tool", Input: map[string]any{}}, + &sdk.FinishStepPart{FinishReason: sdk.FinishReasonToolCalls}, + ), nil + } + if call == 3 { + parts := make(chan sdk.StreamPart, 1) + parts <- &sdk.TextDeltaPart{Text: "after-tools"} + go func() { <-ctx.Done(); close(parts) }() + return &sdk.StreamResult{Stream: parts}, nil + } + finalInput = cloneProviderMessages(params.Messages) + return closedAgentTestStream(&sdk.TextDeltaPart{Text: "done"}, &sdk.FinishStepPart{FinishReason: sdk.FinishReasonStop}), nil + }) + a := New(Deps{}) + a.SetToolProviders([]agenttools.ToolProvider{staticToolProvider{tools: []sdk.Tool{{ + Name: "held_tool", Parameters: &jsonschema.Schema{Type: "object"}, + Execute: func(ctx *sdk.ToolExecContext, _ any) (any, error) { + if executions.Add(1) > 1 { + return "completed second tool result", nil + } + started <- ctx + select { + case <-release: + return "completed tool result", nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }}}}) + events := a.Stream(ctx, RunConfig{ + Model: &sdk.Model{ID: "mock", Provider: provider}, Messages: []sdk.Message{sdk.UserMessage("original")}, + SupportsToolCall: true, SteerWake: wake, NextModelInputs: &nextInputs, + PendingSteer: func(context.Context) (bool, error) { return pending.Load(), nil }, + OnSteer: func(_ context.Context, index int, _ *sdk.StepResult) error { + if index != 2 { + return errors.New("must not preempt a tool") + } + nextInputs = []sdk.Message{sdk.UserMessage("second change")} + pending.Store(false) + return nil + }, + OnStepCommitted: func(_ context.Context, index int, _ *sdk.StepResult) error { + if index == 0 { + nextInputs = []sdk.Message{sdk.UserMessage("change direction")} + pending.Store(false) + } + return nil + }, + }) + var releaseTimer <-chan time.Time + var toolCtx context.Context + for events != nil { + select { + case toolCtx = <-started: + pending.Store(true) + wake <- struct{}{} + releaseTimer = time.After(25 * time.Millisecond) + case <-releaseTimer: + if toolCtx.Err() != nil { + t.Fatal("steer cancelled the tool") + } + close(release) + releaseTimer = nil + case e, ok := <-events: + switch { + case !ok: + events = nil + case e.Type == EventError || e.Type == EventAgentAbort: + t.Fatalf("tool continuation failed: %+v", e) + case e.Type == EventTextDelta && e.Delta == "after-tools": + pending.Store(true) + wake <- struct{}{} + } + case <-ctx.Done(): + t.Fatal("tool continuation timed out") + } + } + users, secondUsers, results := 0, 0, 0 + for _, message := range finalInput { + if message.Role == sdk.MessageRoleUser && messageContentText(message) == "change direction" { + users++ + } + if message.Role == sdk.MessageRoleTool { + results++ + } + if message.Role == sdk.MessageRoleUser && messageContentText(message) == "second change" { + secondUsers++ + } + } + if calls.Load() != 4 || executions.Load() != 2 || users != 1 || secondUsers != 1 || results != 2 || !immediateInput.Load() { + t.Fatalf("calls=%d tools=%d steer inputs=%d,%d results=%d immediate=%v", calls.Load(), executions.Load(), users, secondUsers, results, immediateInput.Load()) + } +} diff --git a/internal/agent/runtime/native/stream.go b/internal/agent/runtime/native/stream.go index 9a7492348..bbee2bafe 100644 --- a/internal/agent/runtime/native/stream.go +++ b/internal/agent/runtime/native/stream.go @@ -35,6 +35,7 @@ const ( EventAgentAbort = event.AgentAbort EventAbort = event.AgentAbort EventRetry = event.Retry + EventStepEnd = event.StepEnd EventProgress = event.Progress EventError = event.Error ) diff --git a/internal/agent/runtime/native/stream_test.go b/internal/agent/runtime/native/stream_test.go index 9847ff1e5..80e407f7e 100644 --- a/internal/agent/runtime/native/stream_test.go +++ b/internal/agent/runtime/native/stream_test.go @@ -6,6 +6,8 @@ import ( "errors" "reflect" "slices" + "strings" + "sync/atomic" "testing" "time" @@ -103,6 +105,75 @@ func TestAgentStreamObservesReasoningEndBeforeStepCommit(t *testing.T) { } } +func TestAgentStreamReopensAfterFinalSteer(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + var secondInput atomic.Bool + var observedText atomic.Int32 + var continueAfter atomic.Bool + nextInputs := []sdk.Message{} + provider := agentStreamTestProvider(func(_ context.Context, params sdk.GenerateParams) (*sdk.StreamResult, error) { + call := calls.Add(1) + if call == 2 { + for _, message := range params.Messages { + if strings.Contains(messageContentText(message), "change direction") { + secondInput.Store(true) + } + } + } + return closedAgentTestStream(&sdk.StartPart{}, &sdk.StartStepPart{}, &sdk.TextDeltaPart{ID: "text", Text: "answer"}, &sdk.FinishStepPart{FinishReason: sdk.FinishReasonStop}, &sdk.FinishPart{FinishReason: sdk.FinishReasonStop}), nil + }) + a := New(Deps{}) + var commits int + events := a.Stream(context.Background(), RunConfig{ + Model: &sdk.Model{ID: "mock-model", Provider: provider}, + Messages: []sdk.Message{sdk.UserMessage("hello")}, Identity: SessionContext{BotID: "bot-1"}, + ContinueAfterFinal: &continueAfter, NextModelInputs: &nextInputs, + OnProviderStreamEventObserved: func(event StreamEvent) { + if event.Type == EventTextDelta { + observedText.Add(1) + } + }, + OnStepCommitted: func(_ context.Context, _ int, _ *sdk.StepResult) error { + commits++ + if commits == 1 { + nextInputs = []sdk.Message{sdk.UserMessage("change direction")} + continueAfter.Store(true) + } + return nil + }, + }) + var terminal, starts int + var steps []int + for event := range events { + if event.Type == EventAgentStart { + starts++ + } + if event.Type == EventStepEnd { + steps = append(steps, event.StepNumber) + } + if event.IsTerminal() { + terminal++ + } + } + if observedText.Load() != 2 || starts != 1 || len(steps) != 2 || steps[0] != 0 || steps[1] != 1 { + t.Fatalf("duplicate observer/start or wrong step offset: observed=%d starts=%d steps=%v", observedText.Load(), starts, steps) + } + if calls.Load() != 2 || !secondInput.Load() || terminal != 1 { + t.Fatalf("calls=%d second_input=%v terminal=%d", calls.Load(), secondInput.Load(), terminal) + } +} + +func messageContentText(message sdk.Message) string { + var out strings.Builder + for _, part := range message.Content { + if text, ok := part.(sdk.TextPart); ok { + out.WriteString(text.Text) + } + } + return out.String() +} + // TestAgentStreamEmitsToolCallInputStartThenStart asserts that a tool call // produces a lightweight EventToolCallInputStart (name + call ID, no input) // when the SDK emits ToolInputStartPart, followed by a EventToolCallStart @@ -139,12 +210,17 @@ func TestAgentStreamEmitsToolCallInputStartThenStart(t *testing.T) { events = append(events, event) } - if len(events) != 4 { - t.Fatalf("expected 4 events, got %d: %#v", len(events), events) + if len(events) != 5 { + t.Fatalf("expected 5 events, got %d: %#v", len(events), events) } if events[0].Type != EventAgentStart { t.Fatalf("expected first event %q, got %#v", EventAgentStart, events[0]) } + // step_end follows every part of the step and precedes the terminal event; + // it carries the durable step index the following commit uses. + if events[3].Type != EventStepEnd || events[3].StepNumber != 0 { + t.Fatalf("expected step_end for step 0 before agent_end, got %#v", events[3]) + } if events[1].Type != EventToolCallInputStart || events[1].ToolCallID != "call-1" || events[1].ToolName != "write" { t.Fatalf("unexpected tool call input start event: %#v", events[1]) } @@ -158,8 +234,8 @@ func TestAgentStreamEmitsToolCallInputStartThenStart(t *testing.T) { if !reflect.DeepEqual(events[2].Input, expectedInput) { t.Fatalf("expected tool call start input %#v, got %#v", expectedInput, events[2].Input) } - if events[3].Type != EventAgentEnd { - t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[3]) + if events[4].Type != EventAgentEnd { + t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[4]) } if commits != 1 { t.Fatalf("committed steps = %d, want 1", commits) @@ -297,7 +373,7 @@ func TestAgentStreamPersistsInterruptedInferenceStep(t *testing.T) { Messages: []sdk.Message{sdk.UserMessage("keep streaming")}, Identity: SessionContext{BotID: "bot-1"}, OnStepInterrupted: func(callbackCtx context.Context, stepIndex int, step *sdk.StepResult) error { - if callbackCtx.Err() != nil || stepIndex != 0 { + if !errors.Is(context.Cause(callbackCtx), context.Canceled) || stepIndex != 0 { t.Errorf("callback context/index = %v/%d", callbackCtx.Err(), stepIndex) } interrupted = step diff --git a/internal/agent/runtime/native/types.go b/internal/agent/runtime/native/types.go index a32927b16..38c049878 100644 --- a/internal/agent/runtime/native/types.go +++ b/internal/agent/runtime/native/types.go @@ -3,6 +3,7 @@ package native import ( "context" "encoding/json" + "sync/atomic" "time" sdk "github.com/felinics/twilight/sdk" @@ -177,6 +178,7 @@ type RunConfig struct { providerAttemptState *providerAttemptState providerMessageProvenance preparedMessageProvenance preparedStepMessages *stepMessageCapture + initialStepInputs []sdk.Message contextStepFailure func(error) SessionType string LiveToolStream bool @@ -196,6 +198,22 @@ type RunConfig struct { Skills []SkillEntry LoopDetection LoopDetectionConfig Retry RetryConfig + // StepIndexOffset lets an application-owned continuation of the same run + // keep durable step indexes monotonic when the SDK invocation is restarted + // after a final step accepted a steer item. + StepIndexOffset int + // ContinueAfterFinal is set by the durable coordinator when a final model + // step found steer input. Native runtime uses it to reopen the same run. + ContinueAfterFinal *atomic.Bool + NextModelInputs *[]sdk.Message + + // SteerWake announces queue changes; PendingSteer rechecks the authoritative + // queue. OnSteer checkpoints a stopped model attempt and claims its next input. + // These callbacks retain the run context, unlike the cancelled invocation. + SteerWake <-chan struct{} + PendingSteer func(context.Context) (bool, error) + OnSteer func(context.Context, int, *sdk.StepResult) error + SuppressAgentStart bool // PromptCacheTTL controls prompt caching for this run. Empty or // unrecognized values default to 5m. Use "1h" for the long-cache tier @@ -228,7 +246,8 @@ type RunConfig struct { // OnStepInterrupted persists text/reasoning emitted by the current model // call when cancellation arrives before finish-step. Tool-call steps never - // use this path. + // use this path. It retains the original run cancellation cause so the + // persistence adapter can reject ownership loss before detaching for IO. OnStepInterrupted func(ctx context.Context, stepIndex int, step *sdk.StepResult) error // BackgroundManager provides access to the background task system. diff --git a/internal/agent/runtime/session/acceptance/README.md b/internal/agent/runtime/session/acceptance/README.md index 703a5b95c..9cb4706f0 100644 --- a/internal/agent/runtime/session/acceptance/README.md +++ b/internal/agent/runtime/session/acceptance/README.md @@ -114,11 +114,30 @@ container or erase the configured Redis database. The dedicated sets the crash guard explicitly. Do not enable either fault against a shared development or production environment. -## Current implementation boundary - -These are target-contract tests, not compatibility tests for the old -per-WebSocket stream registry. Until the production cutover is complete, cases -that require `run_accepted`, `runtime_subscribe`, the durable ledger, or -cross-instance control are expected to fail. A compiling/skipped suite only -proves that the acceptance harness is valid; it does not prove the runtime -contract is implemented. +## Execution and fault boundaries + +These tests exercise the current public runtime, not the retired per-WebSocket +registry. A skipped or merely compiled suite is not acceptance evidence. + +`TestQueueFollowUpsPreserveRepeatedReorderAndDrain` checks serial follow-up +admission after two reorder operations. `TestQueueSteerDecisionKeepsInputAndHistory` +checks a steer, an ask_user pause, another steer admitted while parked, +and persisted user inputs after the decision continuation. + +`TestQueueSteerPreemptsModel` keeps the previous HTTP model request blocked until +test cleanup. Direct steer, follow-up promotion, silent sampling, consecutive +steers, provider retry, and stopping the continued run must preserve the run ID +and exactly one history input per submission. Each steer starts one successor +model call; the retry case also checks the original request's expected retry. +Cluster runs mutate through the +peer Server. Releasing the original request before asserting replacement would +test only eventual step-boundary consumption and is not a passing steer test. + +`TestSRDUR002PreparedFinishSurvivesProcessCrash` is opt-in with +`MEMOH_SESSION_RUNTIME_ACCEPTANCE_CRASH=1` and requires two Servers. Its temporary +PostgreSQL trigger gates only the generated invocation's terminal UPDATE after +the finishing proposal has committed. The real owner is killed, the gate is +released, and the peer must converge to the original outcome with unchanged +history and an available active slot. The trigger is removed and the owner +restarted in cleanup. This fault intentionally writes test-only database objects; +normal ledger probes remain read-only. Never run it against a shared database. diff --git a/internal/agent/runtime/session/acceptance/fake_model_test.go b/internal/agent/runtime/session/acceptance/fake_model_test.go index bb22f6dee..e6c1abb9f 100644 --- a/internal/agent/runtime/session/acceptance/fake_model_test.go +++ b/internal/agent/runtime/session/acceptance/fake_model_test.go @@ -189,6 +189,12 @@ func (m *fakeModel) handleChatCompletions(writer http.ResponseWriter, request *h directive := parseDirective(userText) m.begin(directive.marker) defer m.finish() + if directive.mode == "retry_block" && m.RequestCount(directive.marker) == 1 { + writeJSON(writer, http.StatusServiceUnavailable, map[string]any{ + "error": map[string]any{"message": "controlled retry", "type": "server_error"}, + }) + return + } requestID := fmt.Sprintf("acceptance-%d", time.Now().UnixNano()) if stream, _ := payload["stream"].(bool); !stream { @@ -251,7 +257,7 @@ func (m *fakeModel) handleChatCompletions(writer http.ResponseWriter, request *h return } } - if directive.mode == "partial_block" { + if directive.mode == "partial_block" || directive.mode == "retry_block" { if err := m.waitForRelease(request.Context(), directive.marker); err != nil { m.markDisconnected(directive.marker) return diff --git a/internal/agent/runtime/session/acceptance/queue_contract_test.go b/internal/agent/runtime/session/acceptance/queue_contract_test.go new file mode 100644 index 000000000..6a7151732 --- /dev/null +++ b/internal/agent/runtime/session/acceptance/queue_contract_test.go @@ -0,0 +1,315 @@ +//go:build integration + +package acceptance + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" +) + +func enqueueTestInput(t *testing.T, fixture acceptanceFixture, sessionID, kind, text string) string { + t.Helper() + var item struct { + ID string `json:"item_id"` + } + if err := fixture.api.request(http.MethodPost, "/bots/"+fixture.botID+"/sessions/"+sessionID+"/"+kind+"-queue", map[string]string{"invocation_id": uniqueMarker("queue"), "text": text}, &item, http.StatusAccepted); err != nil { + t.Fatal(err) + } + if item.ID == "" { + t.Fatal("queue response has no item identity") + } + return item.ID +} + +// Never release a blocked request to make steer pass: the new model request +// and cancellation of its predecessor must happen while that predecessor is held. +func TestQueueSteerPreemptsModel(t *testing.T) { + for _, scenario := range []string{"direct", "promoted", "silent", "consecutive", "retry", "stop_after_steer"} { + t.Run(scenario, func(t *testing.T) { + env := loadEnvironment() + fixture := requireFixture(t, env.mode == "cluster") + prepareFakeModel(t) + sessionID := mustCreateSession(t, fixture, "steer-preempt") + origin := uniqueMarker("original") + mode := "partial_block" + if scenario == "silent" { + mode = "block" + } else if scenario == "retry" { + mode = "retry_block" + } + conn := mustDial(t, env.primaryURL, fixture) + defer closeWebSocket(conn) + mustSubscribeAndReadSnapshot(t, conn, sessionID) + _, admitted := mustSendAndAccept(t, fixture, conn, sessionID, origin, directiveMode(origin, 2, 5, mode)) + defer globalFakeModel.Release(origin) + waitBlocked := func(marker string, silent bool) { + t.Helper() + if silent { + if !globalFakeModel.WaitRequestCount(marker, 1, 5*time.Second) { + t.Fatal("original model request never started") + } + } else if _, err := readUntil(conn, 5*time.Second, func(e wsEvent) bool { + return eventsContainString([]wsEvent{e}, marker+"-chunk-01") + }); err != nil { + t.Fatal(err) + } + } + waitBlocked(origin, scenario == "silent") + mutator := fixture + if env.mode == "cluster" { + mutator.api = fixture.api.forBaseURL(env.secondaryURL) + } + markers := []string{origin} + count := 1 + if scenario == "consecutive" { + count = 2 + } + for i := 0; i < count; i++ { + marker := uniqueMarker("steered") + defer globalFakeModel.Release(marker) + blocked := i < count-1 || scenario == "stop_after_steer" + text := directive(marker, 2, 5) + if blocked { + text = directiveMode(marker, 2, 5, "partial_block") + } + started := time.Now() + if scenario == "promoted" { + id := enqueueTestInput(t, mutator, sessionID, "follow-up", text) + if err := mutator.api.request(http.MethodPost, "/bots/"+fixture.botID+"/sessions/"+sessionID+"/follow-up-queue/"+id+"/steer", nil, nil, http.StatusAccepted); err != nil { + t.Fatal(err) + } + } else { + enqueueTestInput(t, mutator, sessionID, "steer", text) + } + if !globalFakeModel.WaitRequestCount(marker, 1, 3*time.Second) || !globalFakeModel.WaitDisconnected(markers[len(markers)-1], time.Second) { + t.Fatal("steer did not replace the blocked invocation") + } + t.Logf("same_run=%s preempt=%d latency=%s", admitted.RunID, i+1, time.Since(started)) + markers = append(markers, marker) + if blocked { + waitBlocked(marker, false) + } + } + terminalState := "completed" + if scenario == "stop_after_steer" { + terminalState = "aborted" + control := uniqueMarker("stop") + if err := sendAbort(conn, sessionID, admitted.RunID, control); err != nil { + t.Fatal(err) + } + if ack := mustReadControlAck(t, conn, control); !ack.Applied { + t.Fatal("steer detached continuation from run abort") + } + } + mustReadRunTerminal(t, conn, admitted.RunID) + run := mustWaitRunState(t, sessionID, origin, func(r sessionRunRecord) bool { return r.State == terminalState }) + if run.RunID != admitted.RunID { + t.Fatal("steer re-admitted a run") + } + history, err := fixture.api.history(fixture.botID, sessionID) + if err != nil { + t.Fatal(err) + } + for _, marker := range markers { + users := 0 + for _, msg := range objectList(history) { + if stringValue(msg["role"]) == "user" && valueContainsString(msg, marker) { + users++ + } + } + requests := 1 + if marker == origin && scenario == "retry" { + requests = 2 + } + if users != 1 || globalFakeModel.RequestCount(marker) != requests { + t.Fatalf("%s: durable users=%d model requests=%d", marker, users, globalFakeModel.RequestCount(marker)) + } + } + }) + } +} + +func TestQueueFollowUpsPreserveRepeatedReorderAndDrain(t *testing.T) { + fixture := requireFixture(t, false) + prepareFakeModel(t) + sessionID := mustCreateSession(t, fixture, "queue-drain") + marker := uniqueMarker("queue-origin") + invocation := "invocation-" + marker + conn := mustDial(t, loadEnvironment().primaryURL, fixture) + defer closeWebSocket(conn) + mustSubscribeAndReadSnapshot(t, conn, sessionID) + _, admitted := mustSendAndAccept(t, fixture, conn, sessionID, invocation, directiveMode(marker, 1, 0, "partial_block")) + if _, err := readUntil(conn, 5*time.Second, func(e wsEvent) bool { + return eventsContainString([]wsEvent{e}, marker+"-chunk-00") + }); err != nil { + t.Fatal(err) + } + defer globalFakeModel.Release(marker) + ids := make([]string, 3) + markers := make([]string, 3) + for i := range ids { + markers[i] = uniqueMarker(fmt.Sprintf("follow-%d", i)) + ids[i] = enqueueTestInput(t, fixture, sessionID, "follow-up", directive(markers[i], 2, 5)) + } + endpoint := "/bots/" + fixture.botID + "/sessions/" + sessionID + "/follow-up-queue/reorder" + for _, move := range [][2]int{{2, 0}, {1, 0}} { + var response any + if err := fixture.api.request(http.MethodPut, endpoint, map[string]any{"item": map[string]string{"item_id": ids[move[0]]}, "before": map[string]string{"item_id": ids[move[1]]}}, &response, http.StatusOK); err != nil { + t.Fatal(err) + } + } + globalFakeModel.Release(marker) + mustReadRunTerminal(t, conn, admitted.RunID) + var priorPosition int64 = -1 + for _, i := range []int{2, 1, 0} { + completed := mustWaitRunState(t, sessionID, "follow-up:"+ids[i], func(run sessionRunRecord) bool { return run.State == "completed" }) + if completed.TurnPosition <= priorPosition { + t.Fatalf("queue order regressed: position %d after %d", completed.TurnPosition, priorPosition) + } + priorPosition = completed.TurnPosition + assertTerminalHistory(t, completed) + if count := globalFakeModel.RequestCount(markers[i]); count != 1 { + t.Fatalf("follow-up %d executed %d times", i, count) + } + } + var queues struct { + FollowUp []any `json:"follow_up"` + } + if err := fixture.api.request(http.MethodGet, "/bots/"+fixture.botID+"/sessions/"+sessionID+"/queue", nil, &queues, http.StatusOK); err != nil { + t.Fatal(err) + } + if len(queues.FollowUp) != 0 { + t.Fatalf("queue did not drain: %v", queues.FollowUp) + } +} + +func TestQueueSteerDecisionKeepsInputAndHistory(t *testing.T) { + testQueueSteerDecisionKeepsInputAndHistory(t, false) +} + +func TestQueueSteerDecisionSurvivesOwnerRestart(t *testing.T) { + if !envBool(crashEnv) { + t.Skipf("set %s=1 only against the isolated acceptance topology", crashEnv) + } + testQueueSteerDecisionKeepsInputAndHistory(t, true) +} + +func testQueueSteerDecisionKeepsInputAndHistory(t *testing.T, restartOwner bool) { + t.Helper() + fixture := requireFixture(t, restartOwner) + prepareFakeModel(t) + sessionID := mustCreateSession(t, fixture, "steer-decision") + marker := uniqueMarker("steer-origin") + invocation := "invocation-" + marker + conn := mustDial(t, loadEnvironment().primaryURL, fixture) + defer closeWebSocket(conn) + mustSubscribeAndReadSnapshot(t, conn, sessionID) + _, admitted := mustSendAndAccept(t, fixture, conn, sessionID, invocation, directiveMode(marker, 1, 0, "partial_block")) + if _, err := readUntil(conn, 5*time.Second, func(e wsEvent) bool { + return eventsContainString([]wsEvent{e}, marker+"-chunk-00") + }); err != nil { + t.Fatal(err) + } + defer globalFakeModel.Release(marker) + steerMarker := uniqueMarker("steer-decision") + steerText := directiveMode(steerMarker, 2, 5, "ask_user") + " ask after steering" + enqueueTestInput(t, fixture, sessionID, "steer", steerText) + waiting := mustWaitRunState(t, sessionID, invocation, func(run sessionRunRecord) bool { return run.State == "waiting_decision" }) + decision := mustPendingUserInput(t, waiting) + answers, err := firstDecisionAnswer(decision.UIPayload) + if err != nil { + t.Fatal(err) + } + afterDecision := directive(uniqueMarker("after-decision"), 2, 5) + " continue after the answer" + enqueueTestInput(t, fixture, sessionID, "steer", afterDecision) + if restartOwner { + env := loadEnvironment() + if err := killAndRestartPrimary(env); err != nil { + t.Fatalf("restart owner after steered decision: %v", err) + } + closeWebSocket(conn) + recovered := mustWaitRunState(t, sessionID, invocation, func(run sessionRunRecord) bool { + return run.State == "waiting_decision" && run.FencingToken > waiting.FencingToken + }) + pending := mustPendingUserInput(t, recovered) + if pending.ID != decision.ID || recovered.RunID != admitted.RunID || recovered.TurnID != waiting.TurnID { + t.Fatalf("recovery changed durable control identity: decision=%+v run=%+v", pending, recovered) + } + conn = mustDial(t, peerURL(env), fixture) + defer closeWebSocket(conn) + mustSubscribeAndReadSnapshot(t, conn, sessionID) + } + control := "control-" + uniqueMarker("steer-answer") + if err := sendUserInputResponse(conn, sessionID, admitted.RunID, decision.ID, control, answers); err != nil { + t.Fatal(err) + } + ack := mustReadControlAck(t, conn, control) + if !ack.Applied { + t.Fatalf("steer decision was not accepted: %+v", ack) + } + mustReadRunCompleted(t, conn, admitted.RunID) + completed := mustWaitRunState(t, sessionID, invocation, func(run sessionRunRecord) bool { return run.State == "completed" }) + history, err := fixture.api.history(fixture.botID, sessionID) + if err != nil { + t.Fatal(err) + } + if !historyContainsRoleText(history, "user", steerText) { + t.Fatalf("applied steer input missing from durable history: %#v", history) + } + if !historyContainsRoleText(history, "user", afterDecision) { + t.Fatalf("post-decision steer missing from history: %#v", history) + } + if completed.RunID != admitted.RunID { + t.Fatal("steer created a second run") + } + ctx, cancel := context.WithTimeout(context.Background(), databaseTimeout) + defer cancel() + if status, err := requireLedger(t).userInputStatus(ctx, decision.ID); err != nil || status != "submitted" { + t.Fatalf("decision status=%s err=%v", status, err) + } + // Presence alone would miss duplicate input or continuation output written + // under the root turn after owner recovery. Inspect explicit DB membership. + rows, err := requireLedger(t).pool.Query(ctx, ` +SELECT turn_id::text, min(turn_position), + count(*) FILTER (WHERE role = 'user'), + count(*) FILTER (WHERE role = 'assistant'), + count(*) FILTER (WHERE role = 'tool'), + count(*) FILTER (WHERE run_id IS DISTINCT FROM $2::uuid) +FROM bot_history_messages +WHERE session_id = $1::uuid +GROUP BY turn_id +ORDER BY min(turn_position)`, sessionID, admitted.RunID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + segments := 0 + for rows.Next() { + var turnID string + var position int64 + var users, assistants, tools, wrongRun int + if err := rows.Scan(&turnID, &position, &users, &assistants, &tools, &wrongRun); err != nil { + t.Fatal(err) + } + segments++ + wantTools := 0 + if segments == 2 { + wantTools = 1 + } + if users != 1 || assistants == 0 || tools != wantTools || wrongRun != 0 || position != int64(segments) { + t.Errorf("segment %d: turn=%s position=%d users=%d assistants=%d tools=%d wrong_run=%d", segments, turnID, position, users, assistants, tools, wrongRun) + } + if (segments == 1) != (turnID == completed.TurnID) { + t.Errorf("segment %d incorrectly uses root control turn %s", segments, completed.TurnID) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if segments != 3 { + t.Fatalf("got %d durable turn segments, want origin + steer + post-decision steer", segments) + } +} diff --git a/internal/agent/runtime/session/acceptance/terminal_crash_test.go b/internal/agent/runtime/session/acceptance/terminal_crash_test.go new file mode 100644 index 000000000..d6284a010 --- /dev/null +++ b/internal/agent/runtime/session/acceptance/terminal_crash_test.go @@ -0,0 +1,148 @@ +//go:build integration + +package acceptance + +import ( + "context" + "fmt" + "net/http" + "os/exec" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// This fault lives in the isolated test database, not in production code. It +// blocks only this invocation's terminal UPDATE, after its immutable proposal +// committed. Killing the real owner in that window must preserve the proposal. +func TestSRDUR002PreparedFinishSurvivesProcessCrash(t *testing.T) { + if !envBool(crashEnv) { + t.Skipf("set %s=1 for the isolated process-crash scenario", crashEnv) + } + fixture := requireFixture(t, true) + prepareFakeModel(t) + env := loadEnvironment() + sessionID := mustCreateSession(t, fixture, "finish-proposal-crash") + marker := uniqueMarker("finish-proposal") + invocation := "invocation-" + marker + conn := mustDial(t, env.primaryURL, fixture) + defer closeWebSocket(conn) + _, admitted := mustSendAndAccept(t, fixture, conn, sessionID, invocation, directiveMode(marker, 2, 0, "block")) + if !globalFakeModel.WaitRequestCount(marker, 1, 5*time.Second) { + t.Fatal("run never reached the model") + } + defer globalFakeModel.Release(marker) + probe := requireLedger(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + dsn := envOr(postgresURLEnv, "") + if dsn == "" { + t.Fatal("the fault test requires an explicit isolated PostgreSQL URL") + } + gate, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + key := time.Now().UnixNano() + name := fmt.Sprintf("acceptance_finish_%d", key) + fn := pgx.Identifier{"public", name}.Sanitize() + trigger := pgx.Identifier{name}.Sanitize() + if _, err := gate.Exec(ctx, "SELECT pg_advisory_lock($1)", key); err != nil { + _ = gate.Close(context.Background()) + t.Fatal(err) + } + unlocked := false + unlock := func() { + if unlocked { + return + } + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := gate.Exec(cleanup, "SELECT pg_advisory_unlock($1)", key); err != nil { + t.Errorf("release terminal gate: %v", err) + } + unlocked = true + } + t.Cleanup(func() { + unlock() + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := gate.Exec(cleanup, "DROP TRIGGER IF EXISTS "+trigger+" ON session_runs; DROP FUNCTION IF EXISTS "+fn+"()"); err != nil { + t.Errorf("remove terminal fault: %v", err) + } + _ = gate.Close(cleanup) + }) + // Identifiers are quoted and the invocation is test-generated, never user SQL. + body := fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $gate$ + BEGIN + IF NEW.invocation_id = '%s' THEN PERFORM pg_advisory_xact_lock(%d); END IF; + RETURN NEW; + END $gate$; + CREATE TRIGGER %s BEFORE UPDATE ON session_runs FOR EACH ROW + WHEN (OLD.state = 'finishing' AND NEW.state IN ('completed','aborted','failed','lost')) + EXECUTE FUNCTION %s()`, fn, strings.ReplaceAll(invocation, "'", "''"), key, trigger, fn) + if _, err := gate.Exec(ctx, body); err != nil { + t.Fatal(err) + } + globalFakeModel.Release(marker) + proposed := mustWaitRunState(t, sessionID, invocation, func(run sessionRunRecord) bool { return run.State == "finishing" }) + var proposal string + if err := probe.pool.QueryRow(ctx, "SELECT proposed_terminal_state FROM session_runs WHERE run_id=$1::uuid", proposed.RunID).Scan(&proposal); err != nil || proposal != "completed" { + t.Fatalf("proposal=%q err=%v", proposal, err) + } + before, err := probe.historySummary(ctx, proposed) + if err != nil || before.UserMessages != 1 || before.AssistantMessages == 0 || before.WrongTurnIDs != 0 { + t.Fatalf("proposal precedes durable output: %+v err=%v", before, err) + } + kill := exec.CommandContext(ctx, "docker", "kill", "--signal=KILL", env.primaryContainer) //nolint:gosec // explicit isolated acceptance container + if output, err := kill.CombinedOutput(); err != nil { + t.Fatalf("kill owner: %v: %s", err, output) + } + t.Cleanup(func() { + restart, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + start := exec.CommandContext(restart, "docker", "start", env.primaryContainer) //nolint:gosec // explicit isolated acceptance container + if output, err := start.CombinedOutput(); err != nil { + t.Errorf("restart owner: %v: %s", err, output) + return + } + client := &http.Client{Timeout: time.Second} + for restart.Err() == nil { + request, _ := http.NewRequestWithContext(restart, http.MethodHead, env.primaryURL+"/health", nil) + response, err := client.Do(request) //nolint:gosec // explicit isolated acceptance URL + if err == nil { + _ = response.Body.Close() + if response.StatusCode == http.StatusOK { + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Error("owner did not restart after fault test") + }) + unlock() + outcome, err := probe.waitRun(ctx, sessionID, invocation, func(run sessionRunRecord) bool { return run.terminal() }) + if err != nil || outcome.State != "completed" { + t.Fatalf("prepared outcome lost after crash: %+v err=%v", outcome, err) + } + if outcome.RunID != admitted.RunID { + t.Fatal("recovery changed run identity") + } + if after := assertTerminalHistory(t, outcome); after != before { + t.Fatalf("recovery duplicated output: before=%+v after=%+v", before, after) + } + peer := mustDial(t, env.secondaryURL, fixture) + defer closeWebSocket(peer) + if err := subscribeRuntime(peer, sessionID); err != nil { + t.Fatal(err) + } + if events, err := readUntil(peer, 10*time.Second, func(event wsEvent) bool { + return eventRunID(event) == outcome.RunID && eventState(event) == "completed" + }); err != nil { + t.Fatalf("durable terminal did not repair live projection: %v events=%+v", err, events) + } + _, next := mustSendAndAccept(t, fixture, peer, sessionID, "next-"+marker, directive(uniqueMarker("after-finish-crash"), 1, 0)) + mustReadRunCompleted(t, peer, next.RunID) +} diff --git a/internal/agent/runtime/session/admit_test.go b/internal/agent/runtime/session/admit_test.go index 2752677b7..073f2a283 100644 --- a/internal/agent/runtime/session/admit_test.go +++ b/internal/agent/runtime/session/admit_test.go @@ -3,7 +3,6 @@ package sessionruntime import ( "context" "errors" - "sort" "strings" "sync" "testing" @@ -11,369 +10,12 @@ import ( "github.com/felinics/memoh/internal/agent/runtime/native" "github.com/felinics/memoh/internal/agent/runtime/session/ledger" + "github.com/felinics/memoh/internal/testutil/sessionledger" ) -// fakeLedger is an in-memory ledger with the same guarantees the PostgreSQL -// adapter provides: one active run per session, fenced idempotent transitions, -// and a monotonic token sequence. It exists so admission ordering can be tested -// without a database; the adapter's own SQL is covered by its integration test. -type fakeLedger struct { - mu sync.Mutex - runs map[string]*ledger.Run - // bySession preserves insertion order so ActiveRun is deterministic. - order []string - token int64 - - admitErr error - claimErr error - tokenErr error - prepareErr error - finalizeErr error - claimHook func(runID string) - - admits int - claims int - finalized []ledger.FinalizeParams -} - -func newFakeLedger() *fakeLedger { - return &fakeLedger{runs: map[string]*ledger.Run{}} -} - -func (f *fakeLedger) Admit(_ context.Context, params ledger.AdmitParams) (ledger.Run, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.admits++ - if f.admitErr != nil { - return ledger.Run{}, false, f.admitErr - } - for _, id := range f.order { - run := f.runs[id] - if run.SessionID != params.SessionID { - continue - } - if run.InvocationID == params.InvocationID { - return *run, false, nil - } - if run.State.Active() { - return ledger.Run{}, false, ledger.ErrSessionBusy - } - } - position := int64(1) - for _, id := range f.order { - if f.runs[id].SessionID == params.SessionID { - position++ - } - } - run := &ledger.Run{ - RunID: params.RunID, - BotID: params.BotID, - SessionID: params.SessionID, - InvocationID: params.InvocationID, - TurnID: params.TurnID, - TurnPosition: position, - State: ledger.StateAccepted, - Input: params.Input, - InputFingerprint: params.InputFingerprint, - CreatedAt: time.Now(), - } - f.runs[run.RunID] = run - f.order = append(f.order, run.RunID) - return *run, true, nil -} - -func (f *fakeLedger) Get(_ context.Context, runID string) (ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - run, ok := f.runs[runID] - if !ok { - return ledger.Run{}, ledger.ErrRunNotFound - } - return *run, nil -} - -func (f *fakeLedger) GetByInvocation(_ context.Context, sessionID, invocationID string) (ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - for _, id := range f.order { - if run := f.runs[id]; run.SessionID == sessionID && run.InvocationID == invocationID { - return *run, nil - } - } - return ledger.Run{}, ledger.ErrRunNotFound -} - -func (f *fakeLedger) ActiveRun(_ context.Context, sessionID string) (ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - for _, id := range f.order { - if run := f.runs[id]; run.SessionID == sessionID && run.State.Active() { - return *run, nil - } - } - return ledger.Run{}, ledger.ErrRunNotFound -} - -func (f *fakeLedger) LatestRun(_ context.Context, sessionID string) (ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - for i := len(f.order) - 1; i >= 0; i-- { - if run := f.runs[f.order[i]]; run.SessionID == sessionID { - return *run, nil - } - } - return ledger.Run{}, ledger.ErrRunNotFound -} - -func (f *fakeLedger) NextFencingToken(context.Context) (int64, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.tokenErr != nil { - return 0, f.tokenErr - } - f.token++ - return f.token, nil -} - -func (f *fakeLedger) Claim(_ context.Context, params ledger.ClaimParams) (ledger.Run, bool, error) { - if f.claimHook != nil { - f.claimHook(params.RunID) - } - f.mu.Lock() - defer f.mu.Unlock() - f.claims++ - if f.claimErr != nil { - return ledger.Run{}, false, f.claimErr - } - run, ok := f.runs[params.RunID] - if !ok { - return ledger.Run{}, false, ledger.ErrRunNotFound - } - if run.State != ledger.StateAccepted || run.FencingToken >= params.FencingToken { - return ledger.Run{}, false, nil - } - run.State = ledger.StateRunning - run.OwnerID = params.OwnerID - run.FencingToken = params.FencingToken - run.LiveGeneration = params.LiveGeneration - run.OwnerSince = time.Now() - return *run, true, nil -} - -func (f *fakeLedger) SetWaitingDecision(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { - return f.transition(runID, token, ledger.StateWaitingDecision) -} - -func (f *fakeLedger) Resume(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { - return f.transition(runID, token, ledger.StateRunning) -} - -func (f *fakeLedger) transition(runID string, token int64, state ledger.State) (ledger.Run, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - run, ok := f.runs[runID] - if !ok || run.FencingToken != token || run.State.Terminal() || run.State == ledger.StateFinishing { - return ledger.Run{}, false, nil - } - run.State = state - return *run, true, nil -} - -func (f *fakeLedger) PrepareFinish(_ context.Context, params ledger.PrepareFinishParams) (ledger.Run, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.prepareErr != nil { - return ledger.Run{}, false, f.prepareErr - } - run, ok := f.runs[params.RunID] - if !ok || run.FencingToken != params.FencingToken || run.State.Terminal() || - (run.State == ledger.StateWaitingDecision && !params.AllowWaitingDecision) { - return ledger.Run{}, false, nil - } - if run.State != ledger.StateFinishing { - run.State = ledger.StateFinishing - run.ProposedState = params.State - run.ProposedErrorCode = params.ErrorCode - run.ProposedErrorMessage = params.ErrorMessage - run.FinishProposedAt = time.Now() - } - return *run, true, nil -} +type fakeLedger = sessionledger.Store -func (f *fakeLedger) Finalize(_ context.Context, params ledger.FinalizeParams) (ledger.Run, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.finalizeErr != nil { - return ledger.Run{}, false, f.finalizeErr - } - run, ok := f.runs[params.RunID] - if !ok || run.FencingToken != params.FencingToken || run.State.Terminal() { - return ledger.Run{}, false, nil - } - state := params.State - errorCode := params.ErrorCode - errorMessage := params.ErrorMessage - if run.State == ledger.StateFinishing { - state = run.ProposedState - errorCode = run.ProposedErrorCode - errorMessage = run.ProposedErrorMessage - } else if state == ledger.StateLost && !run.AbortRequestedAt.IsZero() { - state = ledger.StateAborted - errorCode = "" - errorMessage = "" - } - run.State = state - run.ErrorCode = errorCode - run.ErrorMessage = errorMessage - f.finalized = append(f.finalized, params) - return *run, true, nil -} - -func (f *fakeLedger) RequestAbort(_ context.Context, runID string) (ledger.Run, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - run, ok := f.runs[runID] - if !ok || run.State.Terminal() || run.State == ledger.StateFinishing { - return ledger.Run{}, false, nil - } - run.AbortRequestedAt = time.Now() - return *run, true, nil -} - -// StaleGenerationRuns mirrors the adapter's keyset sweep: active rows that were -// claimed by an incarnation other than the current one, ordered so a cursor can -// page through them. -func (f *fakeLedger) StaleGenerationRuns(_ context.Context, query ledger.StaleGenerationQuery) ([]ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - var matched []ledger.Run - for _, id := range f.order { - run := *f.runs[id] - if !run.State.Active() || run.LiveGeneration == "" || run.LiveGeneration == query.CurrentGeneration { - continue - } - if run.LiveGeneration < query.After.LiveGeneration || - (run.LiveGeneration == query.After.LiveGeneration && run.RunID <= query.After.RunID) { - continue - } - matched = append(matched, run) - } - sort.Slice(matched, func(i, j int) bool { - if matched[i].LiveGeneration != matched[j].LiveGeneration { - return matched[i].LiveGeneration < matched[j].LiveGeneration - } - return matched[i].RunID < matched[j].RunID - }) - if query.Limit > 0 && len(matched) > int(query.Limit) { - matched = matched[:query.Limit] - } - return matched, nil -} - -func (f *fakeLedger) OrphanedRuns(_ context.Context, query ledger.OrphanQuery) ([]ledger.Run, error) { - f.mu.Lock() - defer f.mu.Unlock() - cutoff := time.Now().Add(-query.MinAge) - var matched []ledger.Run - for _, id := range f.order { - run := *f.runs[id] - if run.State != ledger.StateAccepted || run.OwnerID != "" || !run.CreatedAt.Before(cutoff) { - continue - } - matched = append(matched, run) - } - if query.Limit > 0 && len(matched) > int(query.Limit) { - matched = matched[:query.Limit] - } - return matched, nil -} - -// insertOrphan records an admission that committed under a process that died -// before it could claim anything. -func (f *fakeLedger) insertOrphan(runID, sessionID, invocationID, fingerprint string) { - f.mu.Lock() - defer f.mu.Unlock() - run := &ledger.Run{ - RunID: runID, - BotID: testBotID, - SessionID: sessionID, - InvocationID: invocationID, - TurnID: runID + "-turn", - TurnPosition: 1, - State: ledger.StateAccepted, - InputFingerprint: fingerprint, - CreatedAt: time.Now().Add(-time.Hour), - } - f.runs[run.RunID] = run - f.order = append(f.order, run.RunID) -} - -// insertClaimed records a run that some owner took and never finished, which is -// what the reaper finds after that owner disappears. -func (f *fakeLedger) insertClaimed(runID, sessionID string, token int64, generation string) { - f.mu.Lock() - defer f.mu.Unlock() - run := &ledger.Run{ - RunID: runID, - BotID: testBotID, - SessionID: sessionID, - InvocationID: runID + "-inv", - TurnID: runID + "-turn", - TurnPosition: 1, - State: ledger.StateRunning, - OwnerID: "owner-gone", - FencingToken: token, - LiveGeneration: generation, - OwnerSince: time.Now().Add(-time.Minute), - CreatedAt: time.Now().Add(-time.Minute), - } - f.runs[run.RunID] = run - f.order = append(f.order, run.RunID) -} - -func (f *fakeLedger) state(runID string) ledger.State { - f.mu.Lock() - defer f.mu.Unlock() - run, ok := f.runs[runID] - if !ok { - return "" - } - return run.State -} - -func (f *fakeLedger) errorCode(runID string) string { - f.mu.Lock() - defer f.mu.Unlock() - run, ok := f.runs[runID] - if !ok { - return "" - } - return run.ErrorCode -} - -func (f *fakeLedger) setFinalizeErr(err error) { - f.mu.Lock() - defer f.mu.Unlock() - f.finalizeErr = err -} - -func (f *fakeLedger) setPrepareErr(err error) { - f.mu.Lock() - defer f.mu.Unlock() - f.prepareErr = err -} - -func (f *fakeLedger) counts() (admits, claims int) { - f.mu.Lock() - defer f.mu.Unlock() - return f.admits, f.claims -} - -func (f *fakeLedger) terminalWrites() []ledger.FinalizeParams { - f.mu.Lock() - defer f.mu.Unlock() - return append([]ledger.FinalizeParams(nil), f.finalized...) -} +func newFakeLedger() *fakeLedger { return sessionledger.New() } type recordedActivation struct { botID string @@ -602,7 +244,7 @@ func TestAdmitDuplicateInvocationDoesNotExecuteTwice(t *testing.T) { if *f.executions != 1 { t.Fatalf("executions = %d, want 1", *f.executions) } - if _, claims := f.runs.counts(); claims != 1 { + if _, claims := f.runs.Counts(); claims != 1 { t.Fatalf("claims = %d, want 1", claims) } } @@ -667,10 +309,10 @@ func TestAdmitLostClaimReportsStateWithoutStarting(t *testing.T) { t.Parallel() f := newAdmitFixture(t) // Simulate the peer winning between this call's admission and its claim. - f.runs.claimHook = func(runID string) { - f.runs.mu.Lock() - defer f.runs.mu.Unlock() - if run, ok := f.runs.runs[runID]; ok && run.State == ledger.StateAccepted { + f.runs.ClaimHook = func(runID string) { + f.runs.Mu.Lock() + defer f.runs.Mu.Unlock() + if run, ok := f.runs.Runs[runID]; ok && run.State == ledger.StateAccepted { run.State = ledger.StateRunning run.OwnerID = "peer" run.FencingToken = 99 @@ -710,7 +352,7 @@ func TestAdmitReleasesClaimWhenFenceActivationFails(t *testing.T) { if *f.executions != 0 { t.Fatalf("executions = %d, want 0", *f.executions) } - writes := f.runs.terminalWrites() + writes := f.runs.TerminalWrites() if len(writes) != 1 { t.Fatalf("terminal writes = %d, want 1", len(writes)) } @@ -737,7 +379,7 @@ func TestAdmitReleasesClaimWhenExecutionCannotStart(t *testing.T) { if _, err := f.manager.Admit(context.Background(), in); err == nil { t.Fatal("admit should fail when the run cannot start") } - writes := f.runs.terminalWrites() + writes := f.runs.TerminalWrites() if len(writes) != 1 || writes[0].State != ledger.StateFailed { t.Fatalf("terminal writes = %+v, want one failed write", writes) } @@ -753,7 +395,7 @@ func TestAdmitAdoptsOwnerlessAdmission(t *testing.T) { t.Parallel() f := newAdmitFixture(t) in := f.input("inv-orphan", `{"text":"a"}`) - f.runs.insertOrphan("run-orphan", testSessionID, "inv-orphan", fingerprintPayload(in.Payload)) + f.runs.InsertOrphan("run-orphan", testSessionID, "inv-orphan", fingerprintPayload(in.Payload)) admission, err := f.manager.Admit(context.Background(), in) if err != nil { @@ -805,7 +447,7 @@ func TestAdmitRequiresExecutionBuilder(t *testing.T) { if err == nil { t.Fatal("admit without an execution builder should fail") } - if admits, _ := f.runs.counts(); admits != 0 { + if admits, _ := f.runs.Counts(); admits != 0 { t.Fatalf("admits = %d, want 0; validation must precede persistence", admits) } } @@ -832,7 +474,7 @@ func TestAdmittedRunParksAndResumesOnUserInputDecision(t *testing.T) { }); err != nil { t.Fatalf("publish user input request: %v", err) } - if got := fixture.runs.state(admission.RunID); got != ledger.StateWaitingDecision { + if got := fixture.runs.State(admission.RunID); got != ledger.StateWaitingDecision { t.Fatalf("ledger state = %q, want waiting_decision", got) } @@ -859,7 +501,7 @@ func TestAdmittedRunParksAndResumesOnUserInputDecision(t *testing.T) { }); err != nil { t.Fatalf("resume decision run: %v", err) } - if got := fixture.runs.state(admission.RunID); got != ledger.StateRunning { + if got := fixture.runs.State(admission.RunID); got != ledger.StateRunning { t.Fatalf("ledger state after resume = %q, want running", got) } if _, err := fixture.manager.HandleAgentEvent(context.Background(), handle, native.StreamEvent{ @@ -870,7 +512,7 @@ func TestAdmittedRunParksAndResumesOnUserInputDecision(t *testing.T) { if err := fixture.manager.FinishRun(context.Background(), handle, "", ""); err != nil { t.Fatalf("finish resumed run: %v", err) } - if got := fixture.runs.state(admission.RunID); got != ledger.StateCompleted { + if got := fixture.runs.State(admission.RunID); got != ledger.StateCompleted { t.Fatalf("ledger state after completion = %q, want completed", got) } } diff --git a/internal/agent/runtime/session/command_transport_test.go b/internal/agent/runtime/session/command_transport_test.go new file mode 100644 index 000000000..bd80cfedc --- /dev/null +++ b/internal/agent/runtime/session/command_transport_test.go @@ -0,0 +1,84 @@ +package sessionruntime + +import ( + "context" + "strconv" + "strings" + "time" +) + +// dispatchTestCommand supplies explicit command envelopes to the transport +// contract tests. Those tests use live reservations, not durable admission. +// Public decision identity/ledger validation is exercised by RouteDecisionResponse +// tests and the process-level acceptance suite, not a legacy production router. +func (m *Manager) dispatchTestCommand(ctx context.Context, botID, sessionID, commandType, targetID string, payload []byte) (bool, error) { + snapshot, err := m.Snapshot(ctx, botID, sessionID) + if err != nil { + return false, err + } + run := snapshot.CurrentRunView + targetID, present := runtimeCommandTargetID(run, commandType, targetID) + if !present { + return false, nil + } + cmd := Command{ + Type: commandType, ID: testCommandID(botID, sessionID, run, commandType, targetID), + BotID: botID, SessionID: sessionID, RunID: run.RunID, Generation: run.Generation, + TargetID: targetID, DecisionResolved: true, + Payload: payload, PayloadHash: activeCommandPayloadHash(commandType, payload), + } + loadCtx, cancel := context.WithTimeout(ctx, min(m.commandTimeout(), 100*time.Millisecond)) + result, found, err := m.loadCommandResult(loadCtx, cmd.ID) + cancel() + if err != nil { + return true, err + } + if found { + return true, commandResultErrorFor(cmd, result) + } + if !isActiveRunStatus(run.Status) { + return false, nil + } + now, err := m.backend.Now(ctx) + if err != nil { + return true, err + } + cmd.CreatedAt, cmd.ExpiresAt = now, now.Add(m.commandTimeout()) + if m.distributed == nil || run.OwnerID == m.ownerID { + return true, commandResultErrorFor(cmd, m.executeRoutedCommand(ctx, cmd)) + } + return true, m.dispatchRemoteCommand(ctx, run.OwnerID, cmd) +} + +func testCommandID(botID, sessionID string, run *CurrentRunView, commandType, targetID string) string { + return decisionControlCommandID(commandType, botID, targetID, + strings.Join([]string{sessionID, run.RunID, run.Generation}, ":")) +} + +func runtimeCommandTargetID(run *CurrentRunView, commandType, targetID string) (string, bool) { + targetID = strings.TrimSpace(targetID) + if run == nil || targetID == "" { + return "", false + } + for _, message := range run.Messages { + switch commandType { + case CommandToolApprovalResponse: + if message.Approval != nil && (strings.TrimSpace(message.Approval.ApprovalID) == targetID || strconv.Itoa(message.Approval.ShortID) == targetID) { + canonical := strings.TrimSpace(message.Approval.ApprovalID) + if canonical == "" { + canonical = strconv.Itoa(message.Approval.ShortID) + } + return canonical, true + } + case CommandUserInputResponse: + if message.UserInput != nil && (strings.TrimSpace(message.UserInput.UserInputID) == targetID || strconv.Itoa(message.UserInput.ShortID) == targetID) { + canonical := strings.TrimSpace(message.UserInput.UserInputID) + if canonical == "" { + canonical = strconv.Itoa(message.UserInput.ShortID) + } + return canonical, true + } + } + } + return "", false +} diff --git a/internal/agent/runtime/session/commands.go b/internal/agent/runtime/session/commands.go index 7ddccd916..d7cc771d7 100644 --- a/internal/agent/runtime/session/commands.go +++ b/internal/agent/runtime/session/commands.go @@ -8,14 +8,12 @@ import ( "fmt" "log/slog" "sort" - "strconv" "strings" "time" "github.com/google/uuid" "github.com/felinics/memoh/internal/agent/runtime/session/ledger" - "github.com/felinics/memoh/internal/agent/turn" ) func (m *Manager) RunRef(ctx context.Context, botID, sessionID, runID string) (RunRef, bool, error) { @@ -48,19 +46,20 @@ func runHandleForCommand(cmd Command) RunHandle { // DecisionContinuationContext detaches the model continuation from the short // command acknowledgement deadline while keeping it tied to the run owner's // lifecycle and persistence fence. -func (m *Manager) DecisionContinuationContext(cmd Command) (context.Context, context.CancelFunc, error) { +func (m *Manager) DecisionContinuationContext(cmd Command) (context.Context, context.CancelFunc, RunHandle, error) { ctrl := m.localControlForScope(cmd.BotID, cmd.SessionID, cmd.RunID) if ctrl == nil || ctrl.generation != strings.TrimSpace(cmd.Generation) || !ctrl.commandsActive() { - return nil, func() {}, ErrCommandTargetNotActive + return nil, func() {}, RunHandle{}, ErrCommandTargetNotActive } // The acknowledgement request ends before the continuation. Only the run // lifecycle owns this context, so no transport cancellation is attached. ctx, cancel := ctrl.commandContext(context.Background()) - if err := m.ValidateRunOwnership(ctx, runHandleForCommand(cmd)); err != nil { + handle := ctrl.handle() + if err := m.ValidateRunOwnership(ctx, handle); err != nil { cancel() - return nil, func() {}, err + return nil, func() {}, RunHandle{}, err } - return ctx, cancel, nil + return ctx, cancel, handle, nil } // WaitDecisionContinuationReady holds the resumed model call until the stream @@ -634,114 +633,6 @@ func (m *Manager) decisionRunRef(ctx context.Context, target DecisionTarget) (Ru }, true, nil } -// DispatchActiveCommand is the legacy projection-based compatibility entry -// point used by older internal callers. New transports use -// RouteDecisionResponse and never use CurrentRunView.Messages for routing. -func (m *Manager) DispatchActiveCommand(ctx context.Context, botID, sessionID, commandType, targetID string, payload []byte) (bool, error) { - if m == nil || m.backend == nil { - return false, nil - } - botID = strings.TrimSpace(botID) - sessionID = strings.TrimSpace(sessionID) - targetID = strings.TrimSpace(targetID) - if botID == "" || sessionID == "" || targetID == "" { - return false, nil - } - if commandType != CommandToolApprovalResponse && commandType != CommandUserInputResponse { - return false, fmt.Errorf("unsupported active runtime command %q", commandType) - } - snapshot, err := m.Snapshot(ctx, botID, sessionID) - if err != nil { - return false, err - } - run := snapshot.CurrentRunView - if run == nil { - return false, nil - } - canonicalTargetID, targetPresent := runtimeCommandTargetID(run, commandType, targetID) - if !targetPresent { - return false, nil - } - cmd := Command{ - Type: commandType, ID: activeCommandID(botID, sessionID, run, commandType, canonicalTargetID), - BotID: botID, SessionID: sessionID, RunID: strings.TrimSpace(run.RunID), - Generation: strings.TrimSpace(run.Generation), TargetID: canonicalTargetID, - Payload: append([]byte(nil), payload...), PayloadHash: activeCommandPayloadHash(commandType, payload), - } - timeout := m.commandTimeout() - if m.distributed != nil { - loadCtx, cancel := context.WithTimeout(ctx, min(timeout, 100*time.Millisecond)) - result, ok, loadErr := m.loadCommandResult(loadCtx, cmd.ID) - cancel() - if loadErr != nil { - return true, loadErr - } else if ok { - return true, commandResultErrorFor(cmd, result) - } - } - if !isActiveRunStatus(run.Status) { - if reconciled, reconcileErr := m.reconcileRoutedCommand(ctx, cmd); reconciled { - if reconcileErr != nil { - return true, reconcileErr - } - result := m.persistCommandResult(ctx, cmd, reconcileErr) - return true, commandResultErrorFor(cmd, result) - } - return false, nil - } - createdAt, err := m.backend.Now(ctx) - if err != nil { - return true, fmt.Errorf("load runtime command time: %w", err) - } - cmd.CreatedAt = createdAt - cmd.ExpiresAt = createdAt.Add(timeout) - if m.distributed == nil { - commandCtx, cancel, commandErr := m.activeCommandContext(ctx, cmd) - defer cancel() - if commandErr != nil { - return true, commandErr - } - return true, m.applyRoutedCommand(commandCtx, cmd) - } - ownerID := strings.TrimSpace(run.OwnerID) - if ownerID == "" { - return true, errors.New("target runtime owner is unknown") - } - if ownerID == m.ownerID { - result := m.executeRoutedCommand(ctx, cmd) - return true, commandResultErrorFor(cmd, result) - } - dispatchErr := m.dispatchRemoteCommand(ctx, ownerID, cmd) - if dispatchErr != nil { - if reconciled, reconcileErr := m.reconcileRoutedCommand(ctx, cmd); reconciled { - if reconcileErr != nil { - return true, reconcileErr - } - result := m.persistCommandResult(ctx, cmd, reconcileErr) - return true, commandResultErrorFor(cmd, result) - } - } - return true, dispatchErr -} - -// DispatchRunCommand is the transport-facing decision route. In addition to -// the canonical decision id it checks the server-issued run id, preventing a -// stale UI response from being applied to a newer run in the same session. -func (m *Manager) DispatchRunCommand(ctx context.Context, botID, sessionID, runID, commandType, targetID string, payload []byte) (bool, error) { - runID = strings.TrimSpace(runID) - if runID == "" { - return false, nil - } - snapshot, err := m.Snapshot(ctx, botID, sessionID) - if err != nil { - return false, err - } - if snapshot.CurrentRunView == nil || strings.TrimSpace(snapshot.CurrentRunView.RunID) != runID { - return false, nil - } - return m.DispatchActiveCommand(ctx, botID, sessionID, commandType, targetID, payload) -} - func (m *Manager) dispatchRemoteCommand(ctx context.Context, ownerID string, cmd Command) error { ownerID = strings.TrimSpace(ownerID) cmd.ID = strings.TrimSpace(cmd.ID) @@ -781,15 +672,6 @@ func (m *Manager) dispatchRemoteCommand(ctx context.Context, ownerID string, cmd return m.waitCommandResult(ctx, cmd, waiter.result, m.commandTimeout(), ownerID) } -func activeCommandID(botID, sessionID string, run *CurrentRunView, commandType, targetID string) string { - parts := []string{ - strings.TrimSpace(botID), strings.TrimSpace(sessionID), strings.TrimSpace(run.RunID), - strings.TrimSpace(run.Generation), strings.TrimSpace(commandType), strings.TrimSpace(targetID), - } - sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) - return fmt.Sprintf("active-response-%x", sum[:]) -} - func commandPayloadHash(payload []byte) string { sum := sha256.Sum256(payload) return fmt.Sprintf("sha256:%x", sum[:]) @@ -920,124 +802,15 @@ func (m *Manager) requestAbort(ctx context.Context, ctrl *runControl) (bool, err run.UpdatedAt = now return snapshot, true, nil }, func(snapshot Snapshot) RuntimeDelta { - return runtimeRunPatch(snapshot, true, false, false, false) + return runtimeRunPatch(snapshot, true, false, false) }) return acknowledged, err } -func (m *Manager) Steer(ctx context.Context, botID, sessionID, runID, text string) (SteerState, error) { - return m.steer(ctx, botID, sessionID, runID, "", text) -} - -func (m *Manager) SteerRun(ctx context.Context, handle RunHandle, text string) (SteerState, error) { - handle = handle.normalized() - if !handle.valid() { - return SteerState{}, ErrRunOwnershipLost - } - return m.steer(ctx, handle.BotID, handle.SessionID, handle.RunID, handle.Generation, text) -} - -func (m *Manager) steer(ctx context.Context, botID, sessionID, runID, expectedGeneration, text string) (SteerState, error) { - if m == nil || m.backend == nil { - return SteerState{}, errors.New("session runtime manager is not configured") - } - botID = strings.TrimSpace(botID) - sessionID = strings.TrimSpace(sessionID) - runID = strings.TrimSpace(runID) - expectedGeneration = strings.TrimSpace(expectedGeneration) - text = strings.TrimSpace(text) - if botID == "" || sessionID == "" || text == "" { - return SteerState{}, errors.New("bot_id, session_id, and text are required") - } - snapshot, err := m.Snapshot(ctx, botID, sessionID) - if err != nil { - return SteerState{}, err - } - if snapshot.CurrentRunView == nil { - return SteerState{}, errors.New("no active runtime run") - } - if runID == "" { - runID = strings.TrimSpace(snapshot.CurrentRunView.RunID) - } - if snapshot.CurrentRunView.RunID != runID { - return SteerState{}, errors.New("target runtime run is not active") - } - if expectedGeneration != "" && strings.TrimSpace(snapshot.CurrentRunView.Generation) != expectedGeneration { - return SteerState{}, ErrRunOwnershipLost - } - generation := strings.TrimSpace(snapshot.CurrentRunView.Generation) - if expectedGeneration != "" { - generation = expectedGeneration - } - handle := RunHandle{BotID: botID, SessionID: sessionID, RunID: runID, Generation: generation}.normalized() - var steer SteerState - var ownerID string - var commandGeneration string - var commandCreatedAt time.Time - _, _, err = m.updateActiveAndPublish(ctx, handle, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { - if snapshot.CurrentRunView.RunID != runID || !strings.EqualFold(snapshot.CurrentRunView.Status, RunStatusRunning) { - return snapshot, false, errors.New("target runtime run is not active") - } - if snapshot.CurrentRunView.Steer != nil && isPendingSteerStatus(snapshot.CurrentRunView.Steer.Status) { - return snapshot, false, errors.New("another runtime steer command is still pending") - } - steer = SteerState{ - ID: uuid.NewString(), - Status: SteerStatusPending, - Text: text, - CreatedAt: now, - UpdatedAt: now, - } - commandCreatedAt = now - snapshot.Seq++ - snapshot.UpdatedAt = now - snapshot.CurrentRunView.Steer = &steer - snapshot.CurrentRunView.UpdatedAt = now - ownerID = strings.TrimSpace(snapshot.CurrentRunView.OwnerID) - commandGeneration = strings.TrimSpace(snapshot.CurrentRunView.Generation) - return snapshot, true, nil - }, func(snapshot Snapshot) RuntimeDelta { - return runtimeRunPatch(snapshot, false, false, true, false) - }) - if err != nil { - return SteerState{}, err - } - - cmd := Command{ - Type: CommandSteer, BotID: botID, SessionID: sessionID, RunID: runID, - Generation: commandGeneration, SteerID: steer.ID, Text: text, CreatedAt: commandCreatedAt, - ExpiresAt: commandCreatedAt.Add(m.commandTimeout()), - } - if ctrl := m.localControlForHandle(handle); ctrl != nil { - m.applyCommand(ctx, cmd) - } else { - if ownerID == "" { - return steer, errors.New("target runtime owner is unknown") - } - if m.distributed == nil { - return steer, errors.New("active runtime is not local") - } - if err := m.distributed.PublishCommand(ctx, ownerID, cmd); err != nil { - _ = m.updateSteerStatus(context.WithoutCancel(ctx), handle, steer.ID, SteerStatusRejected, err.Error()) - return steer, err - } - } - m.rejectPendingSteerAfterTimeout(context.WithoutCancel(ctx), handle, steer.ID) - return steer, nil -} - func (m *Manager) applyCommand(ctx context.Context, cmd Command) { switch strings.TrimSpace(cmd.Type) { - case CommandAbort, CommandToolApprovalResponse, CommandUserInputResponse, CommandHistoryReset: + case CommandAbort, CommandSteerWake, CommandToolApprovalResponse, CommandUserInputResponse, CommandHistoryReset: m.publishStoredCommandResult(ctx, cmd, m.executeRoutedCommand(ctx, cmd)) - case CommandSteer: - commandCtx, cancel, err := m.activeCommandContext(ctx, cmd) - if err != nil { - _ = m.updateSteerStatus(context.WithoutCancel(ctx), runHandleForCommand(cmd), cmd.SteerID, SteerStatusRejected, steerNotAcknowledgedError) - return - } - m.applySteerCommand(commandCtx, cmd) - cancel() case CommandResult: m.completePendingCommand(cmd) } @@ -1108,10 +881,17 @@ func (m *Manager) applyRoutedCommand(ctx context.Context, cmd Command) error { _, err := m.abortLocal(commandCtx, ctrl) return err } + if strings.TrimSpace(cmd.Type) == CommandSteerWake { + if !run.SteerSupported || (run.Status != RunStatusRunning && run.Status != RunStatusWaitingDecision) { + return ErrCommandTargetNotActive + } + m.wakeSteer(ctrl) + return nil + } if strings.TrimSpace(cmd.Type) == CommandHistoryReset { return m.applyHistoryResetCommand(commandCtx, cmd, ctrl) } - if !cmd.DecisionResolved && !runtimeCommandTargetPresent(run, cmd.Type, cmd.TargetID) { + if !cmd.DecisionResolved { return ErrCommandTargetNotActive } m.mu.Lock() @@ -1157,36 +937,13 @@ func (m *Manager) executeRoutedCommand(ctx context.Context, cmd Command) Command return result } commandCtx, cancel, err := m.activeCommandContext(ctx, cmd) - reconciled := false if err == nil { err = m.applyRoutedCommand(commandCtx, cmd) - if errors.Is(err, ErrCommandTargetNotActive) { - if handled, reconcileErr := m.reconcileRoutedCommand(commandCtx, cmd); handled { - reconciled = true - err = reconcileErr - } - } } cancel() - if reconciled && err != nil { - return newCommandResult(cmd, err) - } return m.persistCommandResult(ctx, cmd, err) } -func (m *Manager) reconcileRoutedCommand(ctx context.Context, cmd Command) (bool, error) { - if m == nil { - return false, nil - } - m.mu.Lock() - reconciler := m.commandReconciler - m.mu.Unlock() - if reconciler == nil { - return false, nil - } - return reconciler(ctx, cmd) -} - func newCommandResult(request Command, err error) Command { result := Command{ Type: CommandResult, ID: request.ID, BotID: request.BotID, SessionID: request.SessionID, @@ -1509,7 +1266,7 @@ func (m *Manager) finishCommandExecution(commandID string, done chan struct{}) { func isDurableRoutedCommand(cmd Command) bool { switch strings.TrimSpace(cmd.Type) { - case CommandAbort, CommandToolApprovalResponse, CommandUserInputResponse, CommandHistoryReset: + case CommandAbort, CommandSteerWake, CommandToolApprovalResponse, CommandUserInputResponse, CommandHistoryReset: return strings.TrimSpace(cmd.ID) != "" default: return false @@ -1538,185 +1295,3 @@ func (m *Manager) releaseCommandAdmission(cmd Command) { delete(m.admittedCommands, strings.TrimSpace(cmd.ID)) m.mu.Unlock() } - -func runtimeCommandTargetID(run *CurrentRunView, commandType, targetID string) (string, bool) { - targetID = strings.TrimSpace(targetID) - if run == nil || targetID == "" { - return "", false - } - for _, message := range run.Messages { - switch commandType { - case CommandToolApprovalResponse: - if message.Approval != nil && (strings.TrimSpace(message.Approval.ApprovalID) == targetID || strconv.Itoa(message.Approval.ShortID) == targetID) { - canonical := strings.TrimSpace(message.Approval.ApprovalID) - if canonical == "" { - canonical = strconv.Itoa(message.Approval.ShortID) - } - return canonical, true - } - case CommandUserInputResponse: - if message.UserInput != nil && (strings.TrimSpace(message.UserInput.UserInputID) == targetID || strconv.Itoa(message.UserInput.ShortID) == targetID) { - canonical := strings.TrimSpace(message.UserInput.UserInputID) - if canonical == "" { - canonical = strconv.Itoa(message.UserInput.ShortID) - } - return canonical, true - } - } - } - return "", false -} - -func runtimeCommandTargetPresent(run *CurrentRunView, commandType, targetID string) bool { - _, ok := runtimeCommandTargetID(run, commandType, targetID) - return ok -} - -func (m *Manager) applySteerCommand(ctx context.Context, cmd Command) { - handle := runHandleForCommand(cmd) - if err := m.ValidateRunOwnership(ctx, handle); err != nil { - _ = m.updateSteerStatus(context.WithoutCancel(ctx), handle, cmd.SteerID, SteerStatusRejected, ErrRunOwnershipLost.Error()) - return - } - if !m.steerCommandIsPending(ctx, cmd) { - return - } - ctrl := m.localControlForScope(cmd.BotID, cmd.SessionID, cmd.RunID) - if ctrl == nil || ctrl.generation != strings.TrimSpace(cmd.Generation) { - _ = m.updateSteerStatus(context.WithoutCancel(ctx), handle, cmd.SteerID, SteerStatusRejected, ErrRunOwnershipLost.Error()) - return - } - errText := "" - if ctrl.injectCh != nil && strings.TrimSpace(cmd.Text) != "" { - queued, err := m.transitionSteerStatus(ctx, handle, cmd.SteerID, SteerStatusQueued, "") - if err != nil { - m.logger.Warn("acknowledge queued steer failed", slog.Any("error", err), slog.String("run_id", cmd.RunID)) - return - } - if !queued { - return - } - sent, sendError := ctrl.sendInject(ctx, turn.InjectMessage{ - Text: strings.TrimSpace(cmd.Text), - Applied: func() { - if err := m.updateSteerStatus(context.WithoutCancel(ctx), handle, cmd.SteerID, SteerStatusApplied, ""); err != nil { - m.logger.Warn("acknowledge applied steer failed", slog.Any("error", err), slog.String("run_id", cmd.RunID)) - } - }, - }) - if sent { - return - } - errText = sendError - } else { - errText = "active runtime is not available" - } - if err := m.updateSteerStatus(context.WithoutCancel(ctx), handle, cmd.SteerID, SteerStatusRejected, errText); err != nil { - m.logger.Warn("update steer status failed", slog.Any("error", err), slog.String("run_id", cmd.RunID)) - } -} - -func (m *Manager) steerCommandIsPending(ctx context.Context, cmd Command) bool { - if strings.TrimSpace(cmd.SteerID) == "" { - return false - } - snapshot, ok, err := m.backend.Load(ctx, Key{BotID: cmd.BotID, SessionID: cmd.SessionID}) - if err != nil { - m.logger.Warn("load steer state failed", slog.Any("error", err), slog.String("run_id", cmd.RunID)) - return false - } - if !ok || !runMatchesHandle(snapshot.CurrentRunView, runHandleForCommand(cmd)) { - return false - } - steer := snapshot.CurrentRunView.Steer - return steer != nil && steer.ID == strings.TrimSpace(cmd.SteerID) && strings.EqualFold(steer.Status, SteerStatusPending) -} - -func (m *Manager) updateSteerStatus(ctx context.Context, handle RunHandle, steerID, status, errText string) error { - _, err := m.transitionSteerStatus(ctx, handle, steerID, status, errText) - return err -} - -func (m *Manager) transitionSteerStatus(ctx context.Context, handle RunHandle, steerID, status, errText string) (bool, error) { - _, changed, err := m.updateActiveAndPublish(ctx, handle, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { - if !runMatchesHandle(snapshot.CurrentRunView, handle) { - return snapshot, false, nil - } - if snapshot.CurrentRunView.Steer == nil || snapshot.CurrentRunView.Steer.ID != steerID { - return snapshot, false, nil - } - currentStatus := snapshot.CurrentRunView.Steer.Status - if !validSteerTransition(currentStatus, status) { - return snapshot, false, nil - } - snapshot.Seq++ - snapshot.UpdatedAt = now - snapshot.CurrentRunView.UpdatedAt = now - snapshot.CurrentRunView.Steer.Status = status - snapshot.CurrentRunView.Steer.Error = strings.TrimSpace(errText) - snapshot.CurrentRunView.Steer.UpdatedAt = now - return snapshot, true, nil - }, func(snapshot Snapshot) RuntimeDelta { - return runtimeRunPatch(snapshot, false, false, true, false) - }) - return changed, err -} - -const steerNotAcknowledgedError = "runtime steer command was not acknowledged" - -func (m *Manager) rejectPendingSteerAfterTimeout(ctx context.Context, handle RunHandle, steerID string) { - timeout := m.commandAckTTL - if timeout <= 0 { - return - } - time.AfterFunc(timeout, func() { - select { - case <-m.closeCh: - return - default: - } - err := m.rejectUnacknowledgedSteer(ctx, handle, steerID) - if err != nil { - m.logger.Warn("reject pending steer failed", slog.Any("error", err), slog.String("run_id", handle.RunID)) - } - }) -} - -func (m *Manager) rejectUnacknowledgedSteer(ctx context.Context, handle RunHandle, steerID string) error { - _, _, err := m.updateActiveAndPublish(ctx, handle, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { - if !runMatchesHandle(snapshot.CurrentRunView, handle) { - return snapshot, false, nil - } - steer := snapshot.CurrentRunView.Steer - if steer == nil || steer.ID != steerID || !strings.EqualFold(steer.Status, SteerStatusPending) { - return snapshot, false, nil - } - snapshot.Seq++ - snapshot.UpdatedAt = now - snapshot.CurrentRunView.UpdatedAt = now - steer.Status = SteerStatusRejected - steer.Error = steerNotAcknowledgedError - steer.UpdatedAt = now - return snapshot, true, nil - }, func(snapshot Snapshot) RuntimeDelta { - return runtimeRunPatch(snapshot, false, false, true, false) - }) - return err -} - -func isPendingSteerStatus(status string) bool { - return strings.EqualFold(status, SteerStatusPending) || strings.EqualFold(status, SteerStatusQueued) -} - -func validSteerTransition(current, next string) bool { - switch strings.ToLower(strings.TrimSpace(next)) { - case SteerStatusQueued: - return strings.EqualFold(current, SteerStatusPending) - case SteerStatusRejected: - return isPendingSteerStatus(current) - case SteerStatusApplied: - return isPendingSteerStatus(current) - default: - return false - } -} diff --git a/internal/agent/runtime/session/decision_output_test.go b/internal/agent/runtime/session/decision_output_test.go index 77fa48592..39db182a0 100644 --- a/internal/agent/runtime/session/decision_output_test.go +++ b/internal/agent/runtime/session/decision_output_test.go @@ -26,7 +26,7 @@ func TestDecisionOutputConcurrentRetriesAndEarlyAcknowledgement(t *testing.T) { token = int64(7) ) runs := newFakeLedger() - runs.insertClaimed(runID, sessionID, token, "live-generation") + runs.InsertClaimed(runID, sessionID, token, "live-generation") if _, applied, err := runs.SetWaitingDecision(context.Background(), runID, token); err != nil || !applied { t.Fatalf("park fake ledger run: applied=%v err=%v", applied, err) } diff --git a/internal/agent/runtime/session/decision_route_test.go b/internal/agent/runtime/session/decision_route_test.go index 11471e3e6..c56218ad8 100644 --- a/internal/agent/runtime/session/decision_route_test.go +++ b/internal/agent/runtime/session/decision_route_test.go @@ -9,99 +9,87 @@ import ( "testing" "time" + "github.com/felinics/memoh/internal/agent/runtime/native" "github.com/felinics/memoh/internal/agent/runtime/session/ledger" chatview "github.com/felinics/memoh/internal/agent/view" ) -func TestRunControlCommandContextPreservesOwnershipLossCause(t *testing.T) { - type contextKey struct{} - lifecycleCtx, lifecycleCancel := context.WithCancel( - context.WithValue(context.Background(), contextKey{}, "run-scope"), - ) - ctrl := &runControl{ - lifecycleCtx: lifecycleCtx, - lifecycleCancel: lifecycleCancel, - } - ctx, cancel := ctrl.commandContext(context.Background()) - defer cancel() - if got := ctx.Value(contextKey{}); got != "run-scope" { - t.Fatalf("command context value = %v, want run-scope", got) - } - - ctrl.revokeOwnership(ErrRunOwnershipLost) - ctrl.stopCommands() - - <-ctx.Done() - if cause := context.Cause(ctx); !errors.Is(cause, ErrRunOwnershipLost) { - t.Fatalf("command context cause = %v, want %v", cause, ErrRunOwnershipLost) +func TestRunControlCommandContextCancellation(t *testing.T) { + for _, tc := range []struct { + name string + deadline time.Duration + trigger func(*runControl, context.CancelCauseFunc) + wantErr, wantCause error + }{ + {"ownership loss", 0, func(ctrl *runControl, _ context.CancelCauseFunc) { + ctrl.revokeOwnership(ErrRunOwnershipLost) + ctrl.stopCommands() + }, context.Canceled, ErrRunOwnershipLost}, + {"deadline expiry", 20 * time.Millisecond, nil, context.DeadlineExceeded, context.DeadlineExceeded}, + // The cause can be DeadlineExceeded even when the future deadline has + // not fired. Err must still be Canceled and propagation immediate. + {"parent cancellation", time.Minute, func(_ *runControl, cancel context.CancelCauseFunc) { + cancel(context.DeadlineExceeded) + }, context.Canceled, context.DeadlineExceeded}, + } { + t.Run(tc.name, func(t *testing.T) { + type contextKey struct{} + lifecycle, stop := context.WithCancel(context.WithValue(context.Background(), contextKey{}, "run-scope")) + defer stop() + ctrl := &runControl{lifecycleCtx: lifecycle, lifecycleCancel: stop} + parent, cancelParent := context.WithCancelCause(context.Background()) + defer cancelParent(nil) + if tc.deadline > 0 { + var cancel context.CancelFunc + parent, cancel = context.WithTimeout(parent, tc.deadline) + defer cancel() + } + ctx, cancel := ctrl.commandContext(parent) + defer cancel() + if _, hasDeadline := ctx.Deadline(); hasDeadline != (tc.deadline > 0) { + t.Fatalf("deadline propagated=%v, want %v", hasDeadline, tc.deadline > 0) + } + if got := ctx.Value(contextKey{}); got != "run-scope" { + t.Fatalf("command context value=%v", got) + } + if tc.trigger != nil { + tc.trigger(ctrl, cancelParent) + } + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("command cancellation did not propagate") + } + if !errors.Is(ctx.Err(), tc.wantErr) || !errors.Is(context.Cause(ctx), tc.wantCause) { + t.Fatalf("Err=%v Cause=%v, want %v / %v", ctx.Err(), context.Cause(ctx), tc.wantErr, tc.wantCause) + } + }) } } -// A command carries the acknowledgement deadline of the request that routed -// it. Relaying only cancellation would report every expiry as context.Canceled -// and hide expired commands from callers that branch on DeadlineExceeded. -func TestRunControlCommandContextKeepsParentDeadline(t *testing.T) { - type contextKey struct{} - lifecycleCtx, lifecycleCancel := context.WithCancel( - context.WithValue(context.Background(), contextKey{}, "run-scope"), - ) - defer lifecycleCancel() - ctrl := &runControl{ - lifecycleCtx: lifecycleCtx, - lifecycleCancel: lifecycleCancel, - } - - parent, cancelParent := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancelParent() - ctx, cancel := ctrl.commandContext(parent) - defer cancel() - if _, ok := ctx.Deadline(); !ok { - t.Fatal("command context has no deadline") - } - if got := ctx.Value(contextKey{}); got != "run-scope" { - t.Fatalf("command context value = %v, want run-scope", got) - } - - select { - case <-ctx.Done(): - case <-time.After(time.Second): - t.Fatal("command context did not expire with its parent") - } - if err := ctx.Err(); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("command context error = %v, want context deadline exceeded", err) - } - if cause := context.Cause(ctx); !errors.Is(cause, context.DeadlineExceeded) { - t.Fatalf("command context cause = %v, want context deadline exceeded", cause) - } -} - -func TestRunControlCommandContextPropagatesCancellationCauseBeforeDeadline(t *testing.T) { - lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background()) - defer lifecycleCancel() - ctrl := &runControl{ - lifecycleCtx: lifecycleCtx, - lifecycleCancel: lifecycleCancel, - } - - sourceCtx, cancelSource := context.WithCancelCause(context.Background()) - parent, cancelDeadline := context.WithDeadline(sourceCtx, time.Now().Add(time.Minute)) - defer cancelDeadline() - ctx, cancel := ctrl.commandContext(parent) - defer cancel() - - // A DeadlineExceeded cause does not mean the deadline itself fired. This - // cancellation must propagate immediately and retain Canceled as ctx.Err(). - cancelSource(context.DeadlineExceeded) - select { - case <-ctx.Done(): - case <-time.After(time.Second): - t.Fatal("command context did not propagate parent cancellation") +// A visible tool block cannot authorize a control command. Only the durable +// decision router may supply the resolved target to owner-side execution. +func TestDecisionCommandRejectsProjectionOnlyTarget(t *testing.T) { + manager := testRuntimeManager(t, NewMemoryBackend(), "projection-only-owner") + handle, err := manager.StartRunHandle(context.Background(), testBotID, testSessionID, testRunID, nil, func() {}, nil) + if err != nil { + t.Fatal(err) } - if err := ctx.Err(); !errors.Is(err, context.Canceled) { - t.Fatalf("command context error = %v, want context canceled", err) + _, err = manager.HandleAgentEvent(context.Background(), handle, native.StreamEvent{ + Type: native.EventUserInputRequest, ToolName: "ask_user", ToolCallID: "call-projection", + UserInputID: "decision-projection", Status: "pending", + }) + if err != nil { + t.Fatal(err) } - if cause := context.Cause(ctx); !errors.Is(cause, context.DeadlineExceeded) { - t.Fatalf("command context cause = %v, want context deadline exceeded", cause) + called := false + manager.SetCommandHandler(func(context.Context, Command) error { called = true; return nil }) + err = manager.applyRoutedCommand(context.Background(), Command{ + Type: CommandUserInputResponse, BotID: testBotID, SessionID: testSessionID, + RunID: testRunID, Generation: handle.Generation, TargetID: "decision-projection", + }) + if !errors.Is(err, ErrCommandTargetNotActive) || called { + t.Fatalf("unresolved command: err=%v handler called=%v", err, called) } } @@ -143,7 +131,7 @@ func TestRouteDecisionResponseUsesDurableTargetAndReplaysAfterTerminal(t *testin token = int64(7) ) runs := newFakeLedger() - runs.insertClaimed(runID, sessionID, token, "live-generation") + runs.InsertClaimed(runID, sessionID, token, "live-generation") if _, applied, err := runs.SetWaitingDecision(context.Background(), runID, token); err != nil || !applied { t.Fatalf("park fake ledger run: applied=%v err=%v", applied, err) } @@ -233,3 +221,71 @@ func TestRouteDecisionResponseUsesDurableTargetAndReplaysAfterTerminal(t *testin } var _ ledger.Store = (*fakeLedger)(nil) + +// Exercise the actual durable decision ingress on both shared backends, with +// no decision present in the UI projection. The transport-only tests construct +// command envelopes directly and cannot substitute for this contract. +func runDistributedDecisionRouteContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { + t.Helper() + ctx := context.Background() + runs := newFakeLedger() + backends := suite.newSharedBackends(t, 2) + owner := testRuntimeManagerWithOptions(t, backends[0], Options{OwnerID: "decision-owner", Ledger: runs, Fence: &fakeFence{}, OwnerLeaseTTL: 2 * time.Second}) + remote := testRuntimeManagerWithOptions(t, backends[1], Options{OwnerID: "decision-remote", Ledger: runs, Fence: &fakeFence{}, OwnerLeaseTTL: 2 * time.Second}) + admission, err := owner.Admit(ctx, AdmitInput{ + BotID: testBotID, SessionID: "durable-decision", InvocationID: "invoke-decision", Payload: []byte(`{"text":"question"}`), + Execution: Execution{Admission: func(context.Context, RunHandle) (RunAdmissionView, error) { return RunAdmissionView{}, nil }, Cancel: func() {}}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := owner.HandleAgentEvent(ctx, admission.Handle, native.StreamEvent{Type: native.EventUserInputRequest, UserInputID: "decision-durable", ToolCallID: "call-durable", Status: "pending"}); err != nil { + t.Fatal(err) + } + store := &fakeDecisionStore{target: DecisionTarget{ + Type: CommandUserInputResponse, ID: "decision-durable", BotID: testBotID, SessionID: "durable-decision", + RunID: admission.RunID, TurnID: admission.TurnID, Status: "pending", FencingToken: admission.Handle.FencingToken, + }} + owner.SetDecisionStore(store) + remote.SetDecisionStore(store) + if _, _, err := backends[0].Update(ctx, Key{BotID: testBotID, SessionID: "durable-decision"}, func(snapshot Snapshot, _ bool) (Snapshot, bool, error) { + snapshot.CurrentRunView.Messages = nil + return snapshot, true, nil + }); err != nil { + t.Fatal(err) + } + var executions atomic.Int32 + owner.SetCommandHandler(func(_ context.Context, cmd Command) error { + if !cmd.DecisionResolved || cmd.FencingToken != admission.Handle.FencingToken { + return errors.New("decision lost its durable ownership") + } + executions.Add(1) + return nil + }) + response := DecisionResponse{Type: CommandUserInputResponse, ControlID: "control-durable", DecisionID: "decision-durable", BotID: testBotID, SessionID: "durable-decision", RunID: admission.RunID, Payload: []byte(`{"answers":[{"question_id":"q1","text":"yes"}]}`)} + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + result, err := remote.RouteDecisionResponse(ctx, response) + if err != nil || !result.Handled || !result.Applied { + t.Errorf("decision result=%+v err=%v", result, err) + } + }() + } + wg.Wait() + if executions.Load() != 1 { + t.Fatalf("duplicate executions=%d", executions.Load()) + } + if err := owner.FinishRun(ctx, admission.Handle, RunStatusAborted, ""); err != nil { + t.Fatal(err) + } + if result, err := remote.RouteDecisionResponse(ctx, response); err != nil || !result.Replayed || !result.Applied { + t.Fatalf("terminal replay=%+v err=%v", result, err) + } + response.Payload = []byte(`{"answers":[{"question_id":"q1","text":"different"}]}`) + if _, err := remote.RouteDecisionResponse(ctx, response); !errors.Is(err, ErrCommandPayloadConflict) { + t.Fatalf("conflicting replay=%v", err) + } +} diff --git a/internal/agent/runtime/session/finalize.go b/internal/agent/runtime/session/finalize.go index b5e907c07..db7bb19e2 100644 --- a/internal/agent/runtime/session/finalize.go +++ b/internal/agent/runtime/session/finalize.go @@ -79,8 +79,8 @@ func (m *Manager) prepareLedgerFinish( // then resolves a prepared proposal to its intended terminal outcome. A run // that never crossed the durable proposal boundary still becomes `lost`. // -// A zero fencing token means the run was started through a pre-ledger entry -// point and has no durable row to transition, not that fencing was skipped. +// Backend-only reservation tests use zero fencing tokens and have no durable +// row to transition. Production admission always supplies a positive token. func (m *Manager) finalizeLedgerRun(ctx context.Context, handle RunHandle, status, errorCode, message string) (TerminalRun, error) { if m.runs == nil || handle.FencingToken <= 0 { return TerminalRun{}, nil diff --git a/internal/agent/runtime/session/inject_ownership_test.go b/internal/agent/runtime/session/inject_ownership_test.go index 219b5b454..68626a8d5 100644 --- a/internal/agent/runtime/session/inject_ownership_test.go +++ b/internal/agent/runtime/session/inject_ownership_test.go @@ -26,7 +26,7 @@ func TestFinishRunStopsInjectSendsWithoutClosingBorrowedChannel(t *testing.T) { steerDone := make(chan struct{}) go func() { <-start - _, _ = manager.Steer(context.Background(), testBotID, sessionID, runID, "race teardown") + _, _ = ctrl.sendInject(context.Background(), turn.InjectMessage{Text: "race teardown"}) close(steerDone) }() close(start) diff --git a/internal/agent/runtime/session/live_queue.go b/internal/agent/runtime/session/live_queue.go new file mode 100644 index 000000000..5e5ff150b --- /dev/null +++ b/internal/agent/runtime/session/live_queue.go @@ -0,0 +1,549 @@ +package sessionruntime + +import ( + "bytes" + "cmp" + "context" + "errors" + "slices" + "sort" + "strings" + "time" +) + +type QueueStatus string + +const ( + QueueAccepted QueueStatus = "accepted" + QueueClaimed QueueStatus = "claimed" + QueueApplied QueueStatus = "applied" + QueueRejected QueueStatus = "rejected" + QueueExpired QueueStatus = "expired" + QueueCanceled QueueStatus = "canceled" +) + +// Stable error codes recorded on rejected queue items. They are runtime +// vocabulary, not transport codes: the item stays readable through the queue +// API until compaction drops it. +const ( + // QueueErrorTargetRunNotActive marks a steer whose target run reached a + // terminal state before the steer entered a model step. + QueueErrorTargetRunNotActive = "queue_target_run_not_active" +) + +const ( + // MaxPendingQueueItems bounds accepted items per queue and session. Queue + // state is one serialized document per queue, so an unbounded pending set + // would grow every mutation's read and write linearly. + MaxPendingQueueItems = 64 + // queueTerminalRetention is how many applied, rejected, and canceled + // items each queue keeps for invocation replay and status lookups. Older + // terminal items are dropped by compaction after every mutation. + queueTerminalRetention = 64 + // terminalClaimRetention bounds the follow-up per-run claim record. It is + // deliberately larger than the item retention so the record outlives the + // items it points at. + terminalClaimRetention = 4 * queueTerminalRetention +) + +var ( + ErrQueueSteerUnsupported = errors.New("queue: active run has no steer consumer") + ErrQueueNoActiveRun = errors.New("queue: no active run") + ErrQueueInvalidReference = errors.New("queue: invalid claim reference") + ErrQueueNotPending = errors.New("queue: item is not an accepted pending item") + ErrQueueInvocationConflict = errors.New("queue: invocation payload conflicts with an existing item") + ErrQueueCapacityExceeded = errors.New("queue: pending capacity exceeded") + ErrLiveQueueUnavailable = errors.New("session runtime queue is unavailable") + ErrQueueAdmissionOverloaded = errors.New("queue: admission overloaded") +) + +type ( + SteerItemID string + FollowUpItemID string +) + +type SteerPendingRef struct { + ItemID SteerItemID `json:"item_id"` +} + +type FollowUpPendingRef struct { + ItemID FollowUpItemID `json:"item_id"` +} + +type SteerClaimRef struct { + ItemID SteerItemID + RunID string + OwnerID string + Generation string + FencingToken int64 + ClaimToken string +} + +type FollowUpClaimRef struct { + ItemID FollowUpItemID + TriggerRunID string + ClaimToken string +} + +type SteerItem struct { + ID SteerItemID + BotID, SessionID, TargetRunID string + InvocationID string + Payload []byte + Status QueueStatus + Position int64 + Claim *SteerClaimRef + // ErrorCode is set when Status is rejected. + ErrorCode string + CreatedAt time.Time +} + +type FollowUpItem struct { + ID FollowUpItemID + BotID, SessionID string + EnqueuedDuringRunID string + InvocationID string + Payload []byte + Status QueueStatus + Position int64 + Claim *FollowUpClaimRef + // ErrorCode is set when Status is rejected. + ErrorCode string + CreatedAt time.Time +} + +type PromoteFollowUpResult struct { + FollowUp FollowUpPendingRef + Steer SteerItem +} + +// LiveQueueBackend is transient session coordination. Implementations must +// serialize each operation with the live run state for the same session. +type LiveQueueBackend interface { + EnqueueSteer(context.Context, Key, string, string, []byte) (SteerItem, error) + EnqueueFollowUp(context.Context, Key, string, string, []byte) (FollowUpItem, error) + PendingQueues(context.Context, Key, int) ([]SteerItem, []FollowUpItem, error) + ReorderSteer(context.Context, Key, SteerPendingRef, SteerPendingRef) ([]SteerItem, error) + ReorderFollowUp(context.Context, Key, FollowUpPendingRef, FollowUpPendingRef) ([]FollowUpItem, error) + UpdateSteer(context.Context, Key, SteerItemID, []byte) (SteerItem, error) + UpdateFollowUp(context.Context, Key, FollowUpItemID, []byte) (FollowUpItem, error) + CancelSteer(context.Context, Key, SteerItemID) error + CancelFollowUp(context.Context, Key, FollowUpItemID) error + PromoteFollowUpToSteer(context.Context, Key, FollowUpPendingRef) (PromoteFollowUpResult, error) + ClaimNextSteer(context.Context, RunHandle, bool) (SteerItem, SteerClaimRef, bool, error) + ApplySteer(context.Context, Key, SteerClaimRef) error + ReleaseSteer(context.Context, Key, SteerClaimRef) error + // CloseSteerRun rejects every accepted or claimed steer that targets the + // given run and seals the run against later steer admission. It is called + // once the run is durably terminal; a steer is bound to its run and has no + // meaning for any later run of the session. + CloseSteerRun(context.Context, Key, string) error + ClaimNextFollowUp(context.Context, Key, string) (FollowUpItem, FollowUpClaimRef, bool, error) + ApplyFollowUp(context.Context, Key, FollowUpClaimRef) error + ReleaseFollowUp(context.Context, Key, FollowUpClaimRef) error +} + +type steerQueueState struct { + Items []SteerItem `json:"items"` + PromotedFollowUpItems map[string]string `json:"promoted_follow_up_items,omitempty"` + ClosedRunID string `json:"closed_run_id,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type followUpQueueState struct { + Items []FollowUpItem `json:"items"` + TerminalClaims map[string]string `json:"terminal_claims,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SteerRunAvailable reports whether the current executor accepts new step inputs. +func SteerRunAvailable(run *CurrentRunView) bool { + return run != nil && run.SteerSupported && (run.Status == RunStatusRunning || run.Status == RunStatusWaitingDecision) +} + +func activeRun(snapshot Snapshot, ok bool) (*CurrentRunView, bool) { + if !ok || snapshot.CurrentRunView == nil || !isActiveRunStatus(snapshot.CurrentRunView.Status) { + return nil, false + } + return snapshot.CurrentRunView, true +} + +func validateQueueKey(key Key) error { + if strings.TrimSpace(key.BotID) == "" || strings.TrimSpace(key.SessionID) == "" { + return ErrQueueInvalidReference + } + return nil +} + +func validatePayload(payload []byte) error { + if len(payload) == 0 { + return ErrQueueInvalidReference + } + return nil +} + +func validateSteerClaim(key Key, ref SteerClaimRef) error { + if err := validateQueueKey(key); err != nil { + return err + } + if ref.ItemID == "" || strings.TrimSpace(ref.RunID) == "" || strings.TrimSpace(ref.OwnerID) == "" || + strings.TrimSpace(ref.Generation) == "" || ref.FencingToken <= 0 || strings.TrimSpace(ref.ClaimToken) == "" { + return ErrQueueInvalidReference + } + return nil +} + +func validateFollowUpClaim(key Key, ref FollowUpClaimRef) error { + if err := validateQueueKey(key); err != nil { + return err + } + if ref.ItemID == "" || strings.TrimSpace(ref.TriggerRunID) == "" || strings.TrimSpace(ref.ClaimToken) == "" { + return ErrQueueInvalidReference + } + return nil +} + +// advanceSteerClaim moves an unapplied claim to the run's current execution +// identity after an owner change. The consumer run and claim token stay the +// same, so this is one logical consumption continued by a new owner, and the +// previous owner's reference no longer matches the stored claim. +func advanceSteerClaim(item *SteerItem, handle RunHandle) bool { + if item == nil || item.Claim == nil || item.Status != QueueClaimed || item.Claim.RunID != handle.RunID { + return false + } + claim := item.Claim + if claim.OwnerID == handle.OwnerID && claim.Generation == handle.Generation && claim.FencingToken == handle.FencingToken { + return false + } + claim.OwnerID = handle.OwnerID + claim.Generation = handle.Generation + claim.FencingToken = handle.FencingToken + return true +} + +// runViewOwnedBy reports whether the projected run is owned by ownerID. The +// memory backend has a single process and never records an owner on the live +// run view, so an empty view owner matches any handle; a distributed backend +// records the owner and must match it exactly. +func runViewOwnedBy(run *CurrentRunView, ownerID string) bool { + if run == nil { + return false + } + viewOwner := strings.TrimSpace(run.OwnerID) + return viewOwner == "" || viewOwner == strings.TrimSpace(ownerID) +} + +func runMatchesSteerClaim(run *CurrentRunView, ref SteerClaimRef) bool { + return run != nil && run.RunID == ref.RunID && runViewOwnedBy(run, ref.OwnerID) && run.Generation == ref.Generation && isActiveRunStatus(run.Status) +} + +func cloneSteerItem(item SteerItem) SteerItem { + item.Payload = append([]byte(nil), item.Payload...) + if item.Claim != nil { + claim := *item.Claim + item.Claim = &claim + } + return item +} + +func cloneFollowUpItem(item FollowUpItem) FollowUpItem { + item.Payload = append([]byte(nil), item.Payload...) + if item.Claim != nil { + claim := *item.Claim + item.Claim = &claim + } + return item +} + +func pendingSteers(state steerQueueState, limit int) []SteerItem { + items := make([]SteerItem, 0, len(state.Items)) + for _, item := range state.Items { + if item.Status == QueueAccepted { + items = append(items, cloneSteerItem(item)) + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].Position < items[j].Position }) + if limit > 0 && len(items) > limit { + items = items[:limit] + } + return items +} + +func pendingFollowUps(state followUpQueueState, limit int) []FollowUpItem { + items := make([]FollowUpItem, 0, len(state.Items)) + for _, item := range state.Items { + if item.Status == QueueAccepted { + items = append(items, cloneFollowUpItem(item)) + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].Position < items[j].Position }) + if limit > 0 && len(items) > limit { + items = items[:limit] + } + return items +} + +func countPendingSteers(state steerQueueState) int { + count := 0 + for _, item := range state.Items { + if item.Status == QueueAccepted { + count++ + } + } + return count +} + +func countPendingFollowUps(state followUpQueueState) int { + count := 0 + for _, item := range state.Items { + if item.Status == QueueAccepted { + count++ + } + } + return count +} + +// closeSteerRun rejects the run's unapplied steers and records the run as +// closed so a steer admitted after the terminal decision is refused even while +// the live snapshot still shows the run as active. It reports whether any +// item changed. +func closeSteerRun(state *steerQueueState, runID string, now time.Time) bool { + changed := false + for i := range state.Items { + item := &state.Items[i] + if item.TargetRunID != runID || (item.Status != QueueAccepted && item.Status != QueueClaimed) { + continue + } + item.Status = QueueRejected + item.ErrorCode = QueueErrorTargetRunNotActive + item.Claim = nil + changed = true + } + if state.ClosedRunID != runID { + state.ClosedRunID = runID + changed = true + } + if changed { + state.UpdatedAt = now + } + return changed +} + +func (s QueueStatus) terminal() bool { + switch s { + case QueueApplied, QueueRejected, QueueExpired, QueueCanceled: + return true + default: + return false + } +} + +// compact keeps every accepted or claimed item and only the newest terminal +// items, so a long-lived session's queue document stays bounded. Map entries +// that point at dropped items are removed with them. +func (state *steerQueueState) compact() { + if state == nil { + return + } + state.Items = compactQueueItems(state.Items, func(item SteerItem) (QueueStatus, time.Time, int64) { + return item.Status, item.CreatedAt, item.Position + }) + if len(state.PromotedFollowUpItems) == 0 { + return + } + present := make(map[string]struct{}, len(state.Items)) + for _, item := range state.Items { + present[string(item.ID)] = struct{}{} + } + for followUpID, steerID := range state.PromotedFollowUpItems { + if _, ok := present[steerID]; !ok { + delete(state.PromotedFollowUpItems, followUpID) + } + } +} + +func (state *followUpQueueState) compact() { + if state == nil { + return + } + state.Items = compactQueueItems(state.Items, func(item FollowUpItem) (QueueStatus, time.Time, int64) { + return item.Status, item.CreatedAt, item.Position + }) + // TerminalClaims is the per-trigger-run idempotency record: a repeated + // terminal observation for the same run must find its entry and claim + // nothing more, even after retention dropped the applied item. Entries + // therefore outlive their items and are only pruned once the map itself + // grows past its bound. + if len(state.TerminalClaims) <= terminalClaimRetention { + return + } + present := make(map[string]struct{}, len(state.Items)) + for _, item := range state.Items { + present[string(item.ID)] = struct{}{} + } + for runID, itemID := range state.TerminalClaims { + if _, ok := present[itemID]; !ok { + delete(state.TerminalClaims, runID) + } + } +} + +func compactQueueItems[T any](items []T, describe func(T) (QueueStatus, time.Time, int64)) []T { + terminal := 0 + for _, item := range items { + if status, _, _ := describe(item); status.terminal() { + terminal++ + } + } + if terminal <= queueTerminalRetention { + return items + } + type indexed struct { + index int + createdAt time.Time + position int64 + } + candidates := make([]indexed, 0, terminal) + for i, item := range items { + if status, createdAt, position := describe(item); status.terminal() { + candidates = append(candidates, indexed{index: i, createdAt: createdAt, position: position}) + } + } + // Oldest first; the head of this order is dropped. + sort.SliceStable(candidates, func(i, j int) bool { + if !candidates[i].createdAt.Equal(candidates[j].createdAt) { + return candidates[i].createdAt.Before(candidates[j].createdAt) + } + return candidates[i].position < candidates[j].position + }) + drop := make(map[int]struct{}, terminal-queueTerminalRetention) + for _, candidate := range candidates[:terminal-queueTerminalRetention] { + drop[candidate.index] = struct{}{} + } + kept := make([]T, 0, len(items)-len(drop)) + for i, item := range items { + if _, dropped := drop[i]; !dropped { + kept = append(kept, item) + } + } + return kept +} + +func nextSteerPosition(state steerQueueState) int64 { + var position int64 + for _, item := range state.Items { + if item.Position > position { + position = item.Position + } + } + return position + 1 +} + +func nextFollowUpPosition(state followUpQueueState) int64 { + var position int64 + for _, item := range state.Items { + if item.Position > position { + position = item.Position + } + } + return position + 1 +} + +// reorderQueuePositions sorts lightweight references to the current positions. +// Only the returned public items need payload copies; ordering must not clone +// a second complete queue or construct an ID-to-position map. +func reorderQueuePositions[T any, ID ~string](items []T, item, before ID, describe func(*T) (ID, QueueStatus, *int64)) error { + if item == "" || item == before { + return ErrQueueInvalidReference + } + type positionRef struct { + id ID + position *int64 + } + pending := make([]positionRef, 0, len(items)) + for i := range items { + id, status, position := describe(&items[i]) + if status == QueueAccepted { + pending = append(pending, positionRef{id: id, position: position}) + } + } + slices.SortStableFunc(pending, func(a, b positionRef) int { return cmp.Compare(*a.position, *b.position) }) + itemIndex, beforeIndex := -1, -1 + for i, ref := range pending { + if ref.id == item { + itemIndex = i + } + if ref.id == before { + beforeIndex = i + } + } + if itemIndex < 0 || (before != "" && beforeIndex < 0) { + return ErrQueueNotPending + } + moving := pending[itemIndex] + pending = append(pending[:itemIndex], pending[itemIndex+1:]...) + if before == "" { + beforeIndex = len(pending) + } else if itemIndex < beforeIndex { + beforeIndex-- + } + pending = append(pending, moving) + copy(pending[beforeIndex+1:], pending[beforeIndex:len(pending)-1]) + pending[beforeIndex] = moving + for i, ref := range pending { + *ref.position = int64(i + 1) + } + return nil +} + +func reorderSteerState(state *steerQueueState, itemRef, beforeRef SteerPendingRef) ([]SteerItem, error) { + if state == nil { + return nil, ErrQueueInvalidReference + } + err := reorderQueuePositions(state.Items, itemRef.ItemID, beforeRef.ItemID, func(item *SteerItem) (SteerItemID, QueueStatus, *int64) { + return item.ID, item.Status, &item.Position + }) + if err != nil { + return nil, err + } + return pendingSteers(*state, 0), nil +} + +func reorderFollowUpState(state *followUpQueueState, itemRef, beforeRef FollowUpPendingRef) ([]FollowUpItem, error) { + if state == nil { + return nil, ErrQueueInvalidReference + } + err := reorderQueuePositions(state.Items, itemRef.ItemID, beforeRef.ItemID, func(item *FollowUpItem) (FollowUpItemID, QueueStatus, *int64) { + return item.ID, item.Status, &item.Position + }) + if err != nil { + return nil, err + } + return pendingFollowUps(*state, 0), nil +} + +func replaySteer(state steerQueueState, invocationID string, payload []byte) (SteerItem, bool, error) { + for _, item := range state.Items { + if item.InvocationID != invocationID { + continue + } + if !bytes.Equal(item.Payload, payload) { + return SteerItem{}, true, ErrQueueInvocationConflict + } + return cloneSteerItem(item), true, nil + } + return SteerItem{}, false, nil +} + +func replayFollowUp(state followUpQueueState, invocationID string, payload []byte) (FollowUpItem, bool, error) { + for _, item := range state.Items { + if item.InvocationID != invocationID { + continue + } + if !bytes.Equal(item.Payload, payload) { + return FollowUpItem{}, true, ErrQueueInvocationConflict + } + return cloneFollowUpItem(item), true, nil + } + return FollowUpItem{}, false, nil +} diff --git a/internal/agent/runtime/session/live_queue_manager.go b/internal/agent/runtime/session/live_queue_manager.go new file mode 100644 index 000000000..8a3e94529 --- /dev/null +++ b/internal/agent/runtime/session/live_queue_manager.go @@ -0,0 +1,244 @@ +package sessionruntime + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" +) + +func (m *Manager) liveQueueBackend() (LiveQueueBackend, error) { + if m == nil || m.backend == nil { + return nil, ErrManagerClosed + } + queue, ok := m.backend.(LiveQueueBackend) + if !ok { + return nil, ErrLiveQueueUnavailable + } + return queue, nil +} + +// EnableSteer advertises an actual execution consumer, not just an allocated +// channel. Other instances therefore reject queues aimed at old/unsupported owners. +func (m *Manager) EnableSteer(ctx context.Context, handle RunHandle) error { + if m.SteerWake(handle) == nil { + return ErrRunOwnershipLost + } + _, _, err := m.updateActiveAndPublish(ctx, handle, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { + run := snapshot.CurrentRunView + if !runMatchesHandle(run, handle) || !m.runOwnerMatches(run) || + (run.Status != RunStatusRunning && run.Status != RunStatusWaitingDecision) { + return snapshot, false, ErrRunOwnershipLost + } + if run.SteerSupported { + return snapshot, false, nil + } + run.SteerSupported = true + run.UpdatedAt = now + snapshot.Seq++ + snapshot.UpdatedAt = now + return snapshot, true, nil + }, func(snapshot Snapshot) RuntimeDelta { return RuntimeDelta{CurrentRunView: snapshot.CurrentRunView} }) + return err +} + +func (m *Manager) EnqueueSteer(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (SteerItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return SteerItem{}, err + } + item, err := queue.EnqueueSteer(ctx, key, itemID, invocationID, payload) + if err == nil && item.Status == QueueAccepted { + m.notifySteer(ctx, key, item.TargetRunID) + } + return item, err +} + +func (m *Manager) EnqueueFollowUp(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (FollowUpItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return FollowUpItem{}, err + } + return queue.EnqueueFollowUp(ctx, key, itemID, invocationID, payload) +} + +func (m *Manager) PendingQueues(ctx context.Context, key Key, limit int) ([]SteerItem, []FollowUpItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return nil, nil, err + } + return queue.PendingQueues(ctx, key, limit) +} + +func (m *Manager) ReorderSteer(ctx context.Context, key Key, item, before SteerPendingRef) ([]SteerItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return nil, err + } + return queue.ReorderSteer(ctx, key, item, before) +} + +func (m *Manager) ReorderFollowUp(ctx context.Context, key Key, item, before FollowUpPendingRef) ([]FollowUpItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return nil, err + } + return queue.ReorderFollowUp(ctx, key, item, before) +} + +func (m *Manager) UpdateSteer(ctx context.Context, key Key, itemID SteerItemID, payload []byte) (SteerItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return SteerItem{}, err + } + return queue.UpdateSteer(ctx, key, itemID, payload) +} + +func (m *Manager) UpdateFollowUp(ctx context.Context, key Key, itemID FollowUpItemID, payload []byte) (FollowUpItem, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return FollowUpItem{}, err + } + return queue.UpdateFollowUp(ctx, key, itemID, payload) +} + +func (m *Manager) CancelSteer(ctx context.Context, key Key, itemID SteerItemID) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.CancelSteer(ctx, key, itemID) +} + +func (m *Manager) CancelFollowUp(ctx context.Context, key Key, itemID FollowUpItemID) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.CancelFollowUp(ctx, key, itemID) +} + +func (m *Manager) PromoteFollowUpToSteer(ctx context.Context, key Key, ref FollowUpPendingRef) (PromoteFollowUpResult, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return PromoteFollowUpResult{}, err + } + result, err := queue.PromoteFollowUpToSteer(ctx, key, ref) + if err == nil && result.Steer.Status == QueueAccepted { + m.notifySteer(ctx, key, result.Steer.TargetRunID) + } + return result, err +} + +// SteerWake is an owner-local, coalesced notification. The queue remains the +// source of truth; neither duplicate notifications nor a stale wake apply input. +func (m *Manager) SteerWake(handle RunHandle) <-chan struct{} { + m.mu.Lock() + defer m.mu.Unlock() + ctrl := m.controls[scopedRunControlKey(handle.BotID, handle.SessionID, handle.RunID)] + if ctrl == nil || ctrl.generation != handle.Generation { + return nil + } + if ctrl.steerWake == nil { + ctrl.steerWake = make(chan struct{}, 1) + } + return ctrl.steerWake +} + +func (m *Manager) wakeSteer(ctrl *runControl) { + m.mu.Lock() + defer m.mu.Unlock() + if m.controls[ctrl.key()] != ctrl || ctrl.steerWake == nil { + return + } + select { + case ctrl.steerWake <- struct{}{}: + default: + } +} + +func (m *Manager) notifySteer(ctx context.Context, key Key, runID string) { + // Input is already accepted. Finish the bounded notification independently + // of the HTTP connection, and never report a delivery error as a rejected input. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), m.commandTimeout()) + defer cancel() + snapshot, ok, err := m.backend.Load(ctx, key) + if err != nil || !ok || snapshot.CurrentRunView == nil || snapshot.CurrentRunView.RunID != runID { + return + } + run := snapshot.CurrentRunView + now, err := m.backend.Now(ctx) + if err != nil { + return + } + cmd := Command{ + Type: CommandSteerWake, ID: uuid.NewString(), BotID: key.BotID, + SessionID: key.SessionID, RunID: runID, Generation: run.Generation, + FencingToken: run.FencingToken, CreatedAt: now, ExpiresAt: now.Add(m.commandTimeout()), + } + if m.runOwnerMatches(run) { + err = m.applyRoutedCommand(ctx, cmd) + } else if m.distributed != nil { + err = m.dispatchRemoteCommand(ctx, run.OwnerID, cmd) + } + if err != nil { + m.logger.Warn("notify accepted steer failed", slog.String("run_id", runID), slog.Any("error", err)) + } +} + +func (m *Manager) ClaimNextSteer(ctx context.Context, handle RunHandle, sealIfEmpty bool) (SteerItem, SteerClaimRef, bool, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return SteerItem{}, SteerClaimRef{}, false, err + } + return queue.ClaimNextSteer(ctx, handle, sealIfEmpty) +} + +func (m *Manager) ApplySteer(ctx context.Context, key Key, ref SteerClaimRef) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.ApplySteer(ctx, key, ref) +} + +func (m *Manager) ReleaseSteer(ctx context.Context, key Key, ref SteerClaimRef) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.ReleaseSteer(ctx, key, ref) +} + +func (m *Manager) CloseSteerRun(ctx context.Context, key Key, runID string) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.CloseSteerRun(ctx, key, runID) +} + +func (m *Manager) ClaimNextFollowUp(ctx context.Context, key Key, triggerRunID string) (FollowUpItem, FollowUpClaimRef, bool, error) { + queue, err := m.liveQueueBackend() + if err != nil { + return FollowUpItem{}, FollowUpClaimRef{}, false, err + } + return queue.ClaimNextFollowUp(ctx, key, triggerRunID) +} + +func (m *Manager) ApplyFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.ApplyFollowUp(ctx, key, ref) +} + +func (m *Manager) ReleaseFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error { + queue, err := m.liveQueueBackend() + if err != nil { + return err + } + return queue.ReleaseFollowUp(ctx, key, ref) +} diff --git a/internal/agent/runtime/session/live_queue_redis_test.go b/internal/agent/runtime/session/live_queue_redis_test.go new file mode 100644 index 000000000..dbdedf797 --- /dev/null +++ b/internal/agent/runtime/session/live_queue_redis_test.go @@ -0,0 +1,61 @@ +package sessionruntime + +import ( + "context" + "os" + "testing" + "time" +) + +// Redis is optional in the normal unit-test environment. When configured, this +// contract deliberately uses two backend instances with one prefix: a queue +// operation that only works inside one process is not a valid Redis backend. +func TestRedisLiveQueueContractOptional(t *testing.T) { + redisURL := os.Getenv("MEMOH_TEST_REDIS_URL") + if redisURL == "" { + redisURL = os.Getenv("MEMOH_TEST_VALKEY_URL") + } + if redisURL == "" { + if os.Getenv("MEMOH_TEST_DISTRIBUTED_REQUIRED") == "1" { + t.Fatal("distributed queue contract requires MEMOH_TEST_REDIS_URL or MEMOH_TEST_VALKEY_URL") + } + t.Skip("set MEMOH_TEST_REDIS_URL or MEMOH_TEST_VALKEY_URL to run the Redis live queue contract") + } + + prefix := uniqueRuntimeBackendPrefix("live-queue") + newBackend := func() *RedisBackend { + backend, err := NewRedisBackend(context.Background(), RedisOptions{ + URL: redisURL, KeyPrefix: prefix, StateTTL: time.Minute, + }) + if err != nil { + t.Fatalf("redis backend: %v", err) + } + t.Cleanup(func() { _ = backend.Close() }) + return backend + } + first, second := newBackend(), newBackend() + ctx := context.Background() + key := Key{BotID: "bot-live-queue", SessionID: "session-live-queue"} + ref := RunRef{ + BotID: key.BotID, SessionID: key.SessionID, RunID: "run-live-queue", + OwnerID: "owner-live-queue", Generation: "generation-live-queue", FencingToken: 41, + } + _, changed, err := first.StartRun(ctx, key, ref, func(snapshot Snapshot, _ bool) (Snapshot, bool, error) { + snapshot.BotID = key.BotID + snapshot.SessionID = key.SessionID + snapshot.CurrentRunView = &CurrentRunView{ + RunID: ref.RunID, OwnerID: ref.OwnerID, Generation: ref.Generation, + SteerSupported: true, Status: RunStatusRunning, + } + return snapshot, true, nil + }) + if err != nil || !changed { + t.Fatalf("seed active run: changed=%v err=%v", changed, err) + } + handle := RunHandle{ + BotID: key.BotID, SessionID: key.SessionID, RunID: ref.RunID, + OwnerID: ref.OwnerID, Generation: ref.Generation, FencingToken: ref.FencingToken, + } + + runLiveQueueContract(t, first, second, key, handle) +} diff --git a/internal/agent/runtime/session/live_queue_test.go b/internal/agent/runtime/session/live_queue_test.go new file mode 100644 index 000000000..edcd6e004 --- /dev/null +++ b/internal/agent/runtime/session/live_queue_test.go @@ -0,0 +1,433 @@ +package sessionruntime + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func liveQueueFixture(t *testing.T) (*MemoryBackend, Key, RunHandle) { + t.Helper() + b := NewMemoryBackend() + key := Key{BotID: "bot", SessionID: "session"} + _, _, err := b.Update(context.Background(), key, func(snapshot Snapshot, _ bool) (Snapshot, bool, error) { + snapshot.BotID, snapshot.SessionID = key.BotID, key.SessionID + snapshot.CurrentRunView = &CurrentRunView{RunID: "run-1", TurnID: "turn-1", Generation: "gen-1", OwnerID: "owner-1", SteerSupported: true, Status: RunStatusRunning} + return snapshot, true, nil + }) + if err != nil { + t.Fatal(err) + } + handle := RunHandle{BotID: key.BotID, SessionID: key.SessionID, RunID: "run-1", OwnerID: "owner-1", Generation: "gen-1", FencingToken: 1} + return b, key, handle +} + +func TestMemoryLiveQueueContract(t *testing.T) { + b, key, handle := liveQueueFixture(t) + runLiveQueueContract(t, b, b, key, handle) +} + +// Memory runs the same sequence as two independent Redis/Valkey clients. +func runLiveQueueContract(t *testing.T, first, second LiveQueueBackend, key Key, handle RunHandle) { + t.Helper() + ctx := context.Background() + steerOne, err := first.EnqueueSteer(ctx, key, "steer-1", "invoke-steer-1", []byte("one")) + require.NoError(t, err, "enqueue steer") + steerTwo, err := second.EnqueueSteer(ctx, key, "steer-2", "invoke-steer-2", []byte("two")) + require.NoError(t, err, "enqueue second steer") + follow, err := second.EnqueueFollowUp(ctx, key, "follow-1", "invoke-steer-1", []byte("follow")) + require.NoError(t, err, "enqueue follow-up") + require.NotEqual(t, string(steerOne.ID), string(follow.ID), "queue identities stay separate") + if steerOne.Position >= steerTwo.Position || follow.Position != 1 { + t.Fatalf("independent positions = steer(%d,%d) follow(%d)", steerOne.Position, steerTwo.Position, follow.Position) + } + + steers, follows, err := first.PendingQueues(ctx, key, 0) + require.NoError(t, err, "list queues from second instance") + if len(steers) != 2 || string(steers[0].Payload) != "one" || string(steers[1].Payload) != "two" { + t.Fatalf("steer FIFO = %#v", steers) + } + if len(follows) != 1 || string(follows[0].Payload) != "follow" { + t.Fatalf("follow-up queue = %#v", follows) + } + + if _, err := first.ReorderSteer(ctx, key, SteerPendingRef{ItemID: steerTwo.ID}, SteerPendingRef{ItemID: steerOne.ID}); err != nil { + t.Fatalf("accepted-only reorder: %v", err) + } + steers, _, err = second.PendingQueues(ctx, key, 0) + if err != nil || len(steers) != 2 || steers[0].ID != steerTwo.ID || steers[1].ID != steerOne.ID { + t.Fatalf("reordered steer queue = %#v, err=%v", steers, err) + } + + claimed, claim, ok, err := second.ClaimNextSteer(ctx, handle, false) + if err != nil || !ok || claimed.ID != steerTwo.ID { + t.Fatalf("claim steer = %#v, %#v, %v, %v", claimed, claim, ok, err) + } + replayed, replayClaim, ok, err := first.ClaimNextSteer(ctx, handle, false) + if err != nil || !ok || replayed.ID != claimed.ID || replayClaim != claim { + t.Fatalf("cross-instance claim replay = %#v, %#v, %v, %v", replayed, replayClaim, ok, err) + } + third, err := first.EnqueueSteer(ctx, key, "steer-3", "invoke-steer-3", []byte("three")) + require.NoError(t, err) + ordered, err := second.ReorderSteer(ctx, key, SteerPendingRef{ItemID: third.ID}, SteerPendingRef{ItemID: steerOne.ID}) + require.NoError(t, err) + require.Len(t, ordered, 2) + require.Equal(t, third.ID, ordered[0].ID) + require.Equal(t, steerOne.ID, ordered[1].ID) + _, err = first.ReorderSteer(ctx, key, SteerPendingRef{ItemID: claimed.ID}, SteerPendingRef{ItemID: steerOne.ID}) + require.ErrorIs(t, err, ErrQueueNotPending) + stale := claim + stale.OwnerID = "stale-owner" + if err := first.ApplySteer(ctx, key, stale); !errors.Is(err, ErrRunOwnershipLost) { + t.Fatalf("stale steer apply = %v", err) + } + if err := first.ApplySteer(ctx, key, claim); err != nil { + t.Fatalf("apply steer: %v", err) + } + if _, err := second.ReorderSteer(ctx, key, SteerPendingRef{ItemID: steerTwo.ID}, SteerPendingRef{ItemID: steerOne.ID}); !errors.Is(err, ErrQueueNotPending) { + t.Fatalf("claimed/applied reorder = %v", err) + } + promotable, err := first.EnqueueFollowUp(ctx, key, "follow-promote", "invoke-follow-promote", []byte("promote")) + require.NoError(t, err, "enqueue promotable follow-up") + promoted, err := second.PromoteFollowUpToSteer(ctx, key, FollowUpPendingRef{ItemID: promotable.ID}) + require.NoError(t, err, "promote follow-up") + if promoted.Steer.ID == "" || string(promoted.Steer.ID) == string(promotable.ID) { + t.Fatalf("promotion reused follow-up identity: follow=%q steer=%q", promotable.ID, promoted.Steer.ID) + } + replayedPromotion, err := first.PromoteFollowUpToSteer(ctx, key, FollowUpPendingRef{ItemID: promotable.ID}) + if err != nil || replayedPromotion.Steer.ID != promoted.Steer.ID { + t.Fatalf("promotion replay = %#v, err=%v", replayedPromotion, err) + } + + steers, follows, err = first.PendingQueues(ctx, key, 0) + require.NoError(t, err) + require.Len(t, steers, 3) + require.Equal(t, promoted.Steer.ID, steers[2].ID) + require.Len(t, follows, 1) + require.Equal(t, follow.ID, follows[0].ID, "only the promoted follow-up should disappear") + + followed, followClaim, ok, err := first.ClaimNextFollowUp(ctx, key, handle.RunID) + if err != nil || !ok || followed.ID != follow.ID { + t.Fatalf("claim follow-up = %#v, %#v, %v, %v", followed, followClaim, ok, err) + } + replayedFollowed, replayFollowClaim, ok, err := second.ClaimNextFollowUp(ctx, key, handle.RunID) + if err != nil || !ok || replayedFollowed.ID != follow.ID || replayFollowClaim != followClaim { + t.Fatalf("cross-instance follow-up replay = %#v, %#v, %v, %v", replayedFollowed, replayFollowClaim, ok, err) + } + require.NoError(t, first.ReleaseFollowUp(ctx, key, followClaim)) + // Release removes the old terminal claim, allowing another trigger to claim. + followed, followClaim, ok, err = second.ClaimNextFollowUp(ctx, key, "after-release") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, follow.ID, followed.ID) + if err := second.ApplyFollowUp(ctx, key, followClaim); err != nil { + t.Fatalf("apply follow-up: %v", err) + } + if _, _, ok, err := first.ClaimNextFollowUp(ctx, key, "different-terminal-run"); err != nil || ok { + t.Fatalf("applied follow-up was claimable again: ok=%v err=%v", ok, err) + } + + // Terminal close from another instance rejects the remaining steers and + // seals the run while its live snapshot is still active. + if err := second.CloseSteerRun(ctx, key, handle.RunID); err != nil { + t.Fatalf("close steer run: %v", err) + } + if steers, _, err := first.PendingQueues(ctx, key, 0); err != nil || len(steers) != 0 { + t.Fatalf("pending steers after close = %#v, err=%v", steers, err) + } + if _, err := first.EnqueueSteer(ctx, key, "steer-late", "invoke-steer-late", []byte("late")); !errors.Is(err, ErrQueueNoActiveRun) { + t.Fatalf("late steer after close = %v, want %v", err, ErrQueueNoActiveRun) + } +} + +func TestMemoryLiveQueueAcceptedOnlyMutationAndReplay(t *testing.T) { + b, key, handle := liveQueueFixture(t) + ctx := context.Background() + item, err := b.EnqueueSteer(ctx, key, "s1", "invoke-1", []byte("one")) + if err != nil { + t.Fatal(err) + } + replay, err := b.EnqueueSteer(ctx, key, "different-id", "invoke-1", []byte("one")) + if err != nil || replay.ID != item.ID { + t.Fatalf("replay = %#v, err = %v", replay, err) + } + if _, err := b.EnqueueSteer(ctx, key, "s2", "invoke-1", []byte("changed")); !errors.Is(err, ErrQueueInvocationConflict) { + t.Fatalf("conflicting replay error = %v", err) + } + if _, err := b.UpdateSteer(ctx, key, item.ID, nil); !errors.Is(err, ErrQueueInvalidReference) { + t.Fatalf("empty update error = %v", err) + } + _, claim, ok, err := b.ClaimNextSteer(ctx, handle, false) + if err != nil || !ok { + t.Fatalf("claim = %#v, %v, %v", item, err, ok) + } + if _, err := b.UpdateSteer(ctx, key, item.ID, []byte("changed")); !errors.Is(err, ErrQueueNotPending) { + t.Fatalf("claimed update error = %v", err) + } + if err := b.ApplySteer(ctx, key, claim); err != nil { + t.Fatal(err) + } + if err := b.CancelSteer(ctx, key, item.ID); !errors.Is(err, ErrQueueNotPending) { + t.Fatalf("applied cancel error = %v", err) + } +} + +func TestMemoryLiveQueueClaimFencingAndSingleWinner(t *testing.T) { + b, key, handle := liveQueueFixture(t) + ctx := context.Background() + item, err := b.EnqueueSteer(ctx, key, "s1", "invoke", []byte("one")) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + claims := make(chan SteerClaimRef, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, claim, ok, err := b.ClaimNextSteer(ctx, handle, false) + if err == nil && ok { + claims <- claim + } + }() + } + wg.Wait() + close(claims) + var claim SteerClaimRef + winners := 0 + for got := range claims { + if claim.ClaimToken != "" && got.ClaimToken != claim.ClaimToken { + t.Fatal("two distinct claim winners") + } + claim = got + winners++ + } + if claim.ClaimToken == "" || winners != 2 { + t.Fatal("no claim winner") + } + stale := claim + stale.OwnerID = "old-owner" + if err := b.ApplySteer(ctx, key, stale); !errors.Is(err, ErrRunOwnershipLost) { + t.Fatalf("stale apply error = %v", err) + } + if err := b.ReleaseSteer(ctx, key, claim); err != nil { + t.Fatal(err) + } + if pending, _, err := b.PendingQueues(ctx, key, 0); err != nil || len(pending) != 1 || pending[0].ID != item.ID { + t.Fatalf("released claim not pending: %#v, %v", pending, err) + } +} + +func TestMemoryCloseSteerRunRejectsPendingAndClaimedSteers(t *testing.T) { + b, key, handle := liveQueueFixture(t) + ctx := context.Background() + for _, id := range []string{"s1", "s2"} { + if _, err := b.EnqueueSteer(ctx, key, id, "invoke-"+id, []byte(id)); err != nil { + t.Fatal(err) + } + } + _, claim, ok, err := b.ClaimNextSteer(ctx, handle, false) + if err != nil || !ok { + t.Fatalf("claim = %v, %v", ok, err) + } + if err := b.CloseSteerRun(ctx, key, handle.RunID); err != nil { + t.Fatalf("close steer run: %v", err) + } + pending, _, err := b.PendingQueues(ctx, key, 0) + if err != nil || len(pending) != 0 { + t.Fatalf("pending after close = %#v, %v", pending, err) + } + b.mu.Lock() + state := b.steerQueues[key.String()] + b.mu.Unlock() + if len(state.Items) != 2 { + t.Fatalf("items after close = %#v", state.Items) + } + for _, item := range state.Items { + if item.Status != QueueRejected || item.ErrorCode != QueueErrorTargetRunNotActive || item.Claim != nil { + t.Fatalf("closed item = %#v", item) + } + } + if state.ClosedRunID != handle.RunID { + t.Fatalf("closed run id = %q, want %q", state.ClosedRunID, handle.RunID) + } + if err := b.ApplySteer(ctx, key, claim); err == nil { + t.Fatal("apply after close succeeded") + } + // The fixture's live snapshot still shows the run as active: the seal must + // refuse a late steer on its own. + if _, err := b.EnqueueSteer(ctx, key, "s3", "invoke-s3", []byte("three")); !errors.Is(err, ErrQueueNoActiveRun) { + t.Fatalf("late enqueue error = %v, want %v", err, ErrQueueNoActiveRun) + } + // Closing a run nobody steered is a no-op. + if err := b.CloseSteerRun(ctx, Key{BotID: "bot", SessionID: "other"}, "run-9"); err != nil { + t.Fatalf("close unknown session: %v", err) + } +} + +func TestMemoryLiveQueueCapacityBound(t *testing.T) { + b, key, _ := liveQueueFixture(t) + ctx := context.Background() + for i := 0; i < MaxPendingQueueItems; i++ { + id := fmt.Sprintf("s%d", i) + if _, err := b.EnqueueSteer(ctx, key, id, "invoke-"+id, []byte(id)); err != nil { + t.Fatalf("enqueue %s: %v", id, err) + } + } + if _, err := b.EnqueueSteer(ctx, key, "overflow", "invoke-overflow", []byte("overflow")); !errors.Is(err, ErrQueueCapacityExceeded) { + t.Fatalf("overflow error = %v, want %v", err, ErrQueueCapacityExceeded) + } + replay, err := b.EnqueueSteer(ctx, key, "ignored", "invoke-s0", []byte("s0")) + if err != nil || replay.ID != "s0" { + t.Fatalf("replay at capacity = %#v, %v", replay, err) + } + // Queues are bounded independently. + follow, err := b.EnqueueFollowUp(ctx, key, "f1", "invoke-f1", []byte("follow")) + require.NoError(t, err, "follow-up enqueue while steer queue is full") + _, err = b.PromoteFollowUpToSteer(ctx, key, FollowUpPendingRef{ItemID: follow.ID}) + require.ErrorIs(t, err, ErrQueueCapacityExceeded) + _, follows, err := b.PendingQueues(ctx, key, 0) + require.NoError(t, err) + require.Len(t, follows, 1) + require.Equal(t, QueueAccepted, follows[0].Status, "failed promotion must preserve the follow-up") + if err := b.CancelSteer(ctx, key, "s0"); err != nil { + t.Fatal(err) + } + if _, err := b.EnqueueSteer(ctx, key, "overflow", "invoke-overflow", []byte("overflow")); err != nil { + t.Fatalf("enqueue after cancel freed capacity: %v", err) + } +} + +func TestMemoryLiveQueueCompactsTerminalItems(t *testing.T) { + b, key, _ := liveQueueFixture(t) + ctx := context.Background() + item, err := b.EnqueueFollowUp(ctx, key, "f-applied", "invoke-f-applied", []byte("first")) + require.NoError(t, err) + _, claim, ok, err := b.ClaimNextFollowUp(ctx, key, "run-done") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, item.ID, claim.ItemID) + require.NoError(t, b.ApplyFollowUp(ctx, key, claim)) + if _, err := b.EnqueueFollowUp(ctx, key, "keep", "invoke-keep", []byte("keep")); err != nil { + t.Fatal(err) + } + total := queueTerminalRetention + 10 + for i := 0; i < total; i++ { + id := FollowUpItemID(fmt.Sprintf("f%d", i)) + if _, err := b.EnqueueFollowUp(ctx, key, string(id), "invoke-"+string(id), []byte(id)); err != nil { + t.Fatal(err) + } + if err := b.CancelFollowUp(ctx, key, id); err != nil { + t.Fatal(err) + } + } + b.mu.Lock() + state := b.followUpQueues[key.String()] + b.mu.Unlock() + terminal, keep := 0, false + ids := make(map[FollowUpItemID]struct{}, len(state.Items)) + for _, item := range state.Items { + ids[item.ID] = struct{}{} + switch { + case item.ID == "keep" && item.Status == QueueAccepted: + keep = true + case item.Status.terminal(): + terminal++ + } + } + if !keep || terminal != queueTerminalRetention { + t.Fatalf("compacted queue keep=%v terminal=%d items=%d", keep, terminal, len(state.Items)) + } + if _, oldest := ids["f0"]; oldest { + t.Fatal("oldest terminal item survived compaction") + } + if _, newest := ids[FollowUpItemID(fmt.Sprintf("f%d", total-1))]; !newest { + t.Fatal("newest terminal item was compacted away") + } + require.NotContains(t, ids, item.ID, "applied item should leave retention") + _, err = b.EnqueueFollowUp(ctx, key, "f-next", "invoke-f-next", []byte("next")) + require.NoError(t, err) + // Compaction must retain the terminal claim even after its item is gone. + _, _, ok, err = b.ClaimNextFollowUp(ctx, key, "run-done") + require.NoError(t, err) + require.False(t, ok, "replayed terminal must not claim a second item") + _, _, ok, err = b.ClaimNextFollowUp(ctx, key, "run-later") + require.NoError(t, err) + require.True(t, ok, "a new terminal can claim pending work") +} + +func TestMemoryClaimNextSteerAdvancesClaimToNewOwner(t *testing.T) { + b, key, oldHandle := liveQueueFixture(t) + ctx := context.Background() + item, err := b.EnqueueSteer(ctx, key, "s1", "invoke-s1", []byte("one")) + if err != nil { + t.Fatal(err) + } + _, oldClaim, ok, err := b.ClaimNextSteer(ctx, oldHandle, false) + if err != nil || !ok { + t.Fatalf("initial claim = %v, %v", ok, err) + } + + // The parked run is reclaimed by another owner with a newer fencing token. + newHandle := oldHandle + newHandle.OwnerID, newHandle.Generation, newHandle.FencingToken = "owner-2", "gen-2", 2 + if _, _, err := b.Update(ctx, key, func(snapshot Snapshot, _ bool) (Snapshot, bool, error) { + snapshot.CurrentRunView.OwnerID, snapshot.CurrentRunView.Generation = newHandle.OwnerID, newHandle.Generation + return snapshot, true, nil + }); err != nil { + t.Fatal(err) + } + + reclaimed, newClaim, ok, err := b.ClaimNextSteer(ctx, newHandle, false) + if err != nil || !ok || reclaimed.ID != item.ID { + t.Fatalf("reclaim = %#v, %v, %v", reclaimed, ok, err) + } + if newClaim.ClaimToken != oldClaim.ClaimToken || newClaim.OwnerID != "owner-2" || newClaim.Generation != "gen-2" || newClaim.FencingToken != 2 { + t.Fatalf("advanced claim = %#v, old = %#v", newClaim, oldClaim) + } + if err := b.ApplySteer(ctx, key, oldClaim); err == nil { + t.Fatal("previous owner applied a claim it no longer holds") + } + if err := b.ApplySteer(ctx, key, newClaim); err != nil { + t.Fatalf("new owner apply: %v", err) + } + if _, _, ok, err := b.ClaimNextSteer(ctx, newHandle, false); err != nil || ok { + t.Fatalf("applied steer was claimable again: ok=%v err=%v", ok, err) + } +} + +// A run admitted through the memory Manager carries the manager's owner on its +// handle, but the memory backend never records an owner on the live run view. +// The queue must treat that admission as the run's owner; otherwise every step +// commit in single-process deployments fails with ErrRunOwnershipLost. +func TestMemoryLiveQueueClaimsForRealAdmission(t *testing.T) { + f := newAdmitFixture(t) + ctx := context.Background() + admission, err := f.manager.Admit(ctx, f.input("inv-queue", `{"text":"hi"}`)) + if err != nil || !admission.Started { + t.Fatalf("admit: %+v, %v", admission, err) + } + if err := f.manager.EnableSteer(ctx, admission.Handle); err != nil { + t.Fatal(err) + } + key := Key{BotID: testBotID, SessionID: testSessionID} + item, err := f.manager.EnqueueSteer(ctx, key, "s1", "invoke-s1", []byte(`{"text":"steer"}`)) + if err != nil { + t.Fatalf("enqueue steer: %v", err) + } + claimed, claim, ok, err := f.manager.ClaimNextSteer(ctx, admission.Handle, false) + if err != nil || !ok || claimed.ID != item.ID { + t.Fatalf("claim with the admission handle = %#v, %v, %v", claimed, ok, err) + } + if err := f.manager.ApplySteer(ctx, key, claim); err != nil { + t.Fatalf("apply with the admission handle: %v", err) + } + if _, _, ok, err := f.manager.ClaimNextSteer(ctx, admission.Handle, true); err != nil || ok { + t.Fatalf("seal after apply = ok:%v err:%v", ok, err) + } + f.finish(t, admission) +} diff --git a/internal/agent/runtime/session/live_queue_transitions.go b/internal/agent/runtime/session/live_queue_transitions.go new file mode 100644 index 000000000..3acbed709 --- /dev/null +++ b/internal/agent/runtime/session/live_queue_transitions.go @@ -0,0 +1,235 @@ +package sessionruntime + +import ( + "time" + + "github.com/google/uuid" +) + +// Queue transitions have one implementation. Backends supply serialization, +// ownership validation and their authoritative clock, then store the result. +func (state *steerQueueState) claimNext(handle RunHandle, sealIfEmpty bool, now time.Time) (SteerItem, SteerClaimRef, bool) { + for i := range state.Items { + item := &state.Items[i] + if item.Status == QueueClaimed && item.Claim != nil && item.Claim.RunID == handle.RunID { + if advanceSteerClaim(item, handle) { + state.UpdatedAt = now + } + return cloneSteerItem(*item), *item.Claim, true + } + } + best := -1 + for i := range state.Items { + if state.Items[i].Status == QueueAccepted && state.Items[i].TargetRunID == handle.RunID && + (best < 0 || state.Items[i].Position < state.Items[best].Position) { + best = i + } + } + if best < 0 { + if sealIfEmpty { + state.ClosedRunID = handle.RunID + state.UpdatedAt = now + } + return SteerItem{}, SteerClaimRef{}, false + } + claim := SteerClaimRef{ItemID: state.Items[best].ID, RunID: handle.RunID, OwnerID: handle.OwnerID, Generation: handle.Generation, FencingToken: handle.FencingToken, ClaimToken: uuid.NewString()} + state.Items[best].Status = QueueClaimed + state.Items[best].Claim = &claim + state.UpdatedAt = now + return cloneSteerItem(state.Items[best]), claim, true +} + +func (state *followUpQueueState) claimNext(triggerRunID string, now time.Time) (FollowUpItem, FollowUpClaimRef, bool) { + if state.TerminalClaims == nil { + state.TerminalClaims = make(map[string]string) + } + if itemID := state.TerminalClaims[triggerRunID]; itemID != "" { + for _, item := range state.Items { + if string(item.ID) == itemID && item.Status == QueueClaimed && item.Claim != nil { + return cloneFollowUpItem(item), *item.Claim, true + } + } + return FollowUpItem{}, FollowUpClaimRef{}, false + } + best := -1 + for i := range state.Items { + if state.Items[i].Status == QueueAccepted && (best < 0 || state.Items[i].Position < state.Items[best].Position) { + best = i + } + } + if best < 0 { + return FollowUpItem{}, FollowUpClaimRef{}, false + } + claim := FollowUpClaimRef{ItemID: state.Items[best].ID, TriggerRunID: triggerRunID, ClaimToken: uuid.NewString()} + state.Items[best].Status = QueueClaimed + state.Items[best].Claim = &claim + state.TerminalClaims[triggerRunID] = string(state.Items[best].ID) + state.UpdatedAt = now + return cloneFollowUpItem(state.Items[best]), claim, true +} + +func (state *steerQueueState) apply(ref SteerClaimRef, now time.Time) error { + for i := range state.Items { + claim := state.Items[i].Claim + if state.Items[i].ID == ref.ItemID && state.Items[i].Status == QueueClaimed && claim != nil && *claim == ref { + state.Items[i].Status = QueueApplied + state.UpdatedAt = now + state.compact() + return nil + } + } + return ErrQueueInvalidReference +} + +func (state *steerQueueState) release(ref SteerClaimRef, now time.Time) error { + for i := range state.Items { + claim := state.Items[i].Claim + if state.Items[i].ID == ref.ItemID && state.Items[i].Status == QueueClaimed && claim != nil && *claim == ref { + state.Items[i].Status = QueueAccepted + state.Items[i].Claim = nil + state.UpdatedAt = now + return nil + } + } + return ErrQueueInvalidReference +} + +func (state *followUpQueueState) apply(ref FollowUpClaimRef, now time.Time) error { + for i := range state.Items { + claim := state.Items[i].Claim + if state.Items[i].ID == ref.ItemID && state.Items[i].Status == QueueClaimed && claim != nil && *claim == ref { + state.Items[i].Status = QueueApplied + state.UpdatedAt = now + state.compact() + return nil + } + } + return ErrQueueInvalidReference +} + +func (state *followUpQueueState) release(ref FollowUpClaimRef, now time.Time) error { + for i := range state.Items { + claim := state.Items[i].Claim + if state.Items[i].ID == ref.ItemID && state.Items[i].Status == QueueClaimed && claim != nil && *claim == ref { + state.Items[i].Status = QueueAccepted + state.Items[i].Claim = nil + delete(state.TerminalClaims, ref.TriggerRunID) + state.UpdatedAt = now + return nil + } + } + return ErrQueueInvalidReference +} + +func (state *steerQueueState) edit(itemID SteerItemID, payload []byte, now time.Time) (SteerItem, error) { + for i := range state.Items { + if state.Items[i].ID == itemID && state.Items[i].Status == QueueAccepted { + state.Items[i].Payload = append([]byte(nil), payload...) + state.UpdatedAt = now + return cloneSteerItem(state.Items[i]), nil + } + } + return SteerItem{}, ErrQueueNotPending +} + +func (state *steerQueueState) cancel(itemID SteerItemID, now time.Time) error { + for i := range state.Items { + if state.Items[i].ID == itemID && state.Items[i].Status == QueueAccepted { + state.Items[i].Status = QueueCanceled + state.UpdatedAt = now + state.compact() + return nil + } + } + return ErrQueueNotPending +} + +func (state *followUpQueueState) edit(itemID FollowUpItemID, payload []byte, now time.Time) (FollowUpItem, error) { + for i := range state.Items { + if state.Items[i].ID == itemID && state.Items[i].Status == QueueAccepted { + state.Items[i].Payload = append([]byte(nil), payload...) + state.UpdatedAt = now + return cloneFollowUpItem(state.Items[i]), nil + } + } + return FollowUpItem{}, ErrQueueNotPending +} + +func (state *followUpQueueState) cancel(itemID FollowUpItemID, now time.Time) error { + for i := range state.Items { + if state.Items[i].ID == itemID && state.Items[i].Status == QueueAccepted { + state.Items[i].Status = QueueCanceled + state.UpdatedAt = now + state.compact() + return nil + } + } + return ErrQueueNotPending +} + +func (steers *steerQueueState) promote(follows *followUpQueueState, key Key, runID string, ref FollowUpPendingRef, now time.Time, newID string) (PromoteFollowUpResult, error) { + if steerID := steers.PromotedFollowUpItems[string(ref.ItemID)]; steerID != "" { + for _, existing := range steers.Items { + if existing.ID == SteerItemID(steerID) { + return PromoteFollowUpResult{FollowUp: ref, Steer: cloneSteerItem(existing)}, nil + } + } + return PromoteFollowUpResult{}, ErrQueueInvalidReference + } + for i := range follows.Items { + if follows.Items[i].ID != ref.ItemID || follows.Items[i].Status != QueueAccepted { + continue + } + if countPendingSteers(*steers) >= MaxPendingQueueItems { + return PromoteFollowUpResult{}, ErrQueueCapacityExceeded + } + steer := SteerItem{ + ID: SteerItemID(newID), BotID: key.BotID, SessionID: key.SessionID, + TargetRunID: runID, InvocationID: "promote:" + string(ref.ItemID), + Payload: append([]byte(nil), follows.Items[i].Payload...), Status: QueueAccepted, + Position: nextSteerPosition(*steers), CreatedAt: now, + } + steers.Items = append(steers.Items, steer) + if steers.PromotedFollowUpItems == nil { + steers.PromotedFollowUpItems = make(map[string]string) + } + steers.PromotedFollowUpItems[string(ref.ItemID)] = string(steer.ID) + steers.UpdatedAt = now + follows.Items[i].Status = QueueCanceled + follows.UpdatedAt = now + follows.compact() + return PromoteFollowUpResult{FollowUp: ref, Steer: cloneSteerItem(steer)}, nil + } + return PromoteFollowUpResult{}, ErrQueueNotPending +} + +func (state *steerQueueState) enqueue(key Key, itemID, invocationID, runID string, payload []byte, now time.Time) (SteerItem, error) { + if countPendingSteers(*state) >= MaxPendingQueueItems { + return SteerItem{}, ErrQueueCapacityExceeded + } + item := SteerItem{ + ID: SteerItemID(itemID), BotID: key.BotID, SessionID: key.SessionID, + TargetRunID: runID, InvocationID: invocationID, Payload: append([]byte(nil), payload...), + Status: QueueAccepted, Position: nextSteerPosition(*state), CreatedAt: now, + } + state.Items = append(state.Items, item) + state.UpdatedAt = now + if state.ClosedRunID != "" && state.ClosedRunID != runID { + state.ClosedRunID = "" + } + return cloneSteerItem(item), nil +} + +func (state *followUpQueueState) enqueue(key Key, itemID, invocationID, runID string, payload []byte, now time.Time) (FollowUpItem, error) { + if countPendingFollowUps(*state) >= MaxPendingQueueItems { + return FollowUpItem{}, ErrQueueCapacityExceeded + } + item := FollowUpItem{ + ID: FollowUpItemID(itemID), BotID: key.BotID, SessionID: key.SessionID, + EnqueuedDuringRunID: runID, InvocationID: invocationID, Payload: append([]byte(nil), payload...), + Status: QueueAccepted, Position: nextFollowUpPosition(*state), CreatedAt: now, + } + state.Items = append(state.Items, item) + state.UpdatedAt = now + return cloneFollowUpItem(item), nil +} diff --git a/internal/agent/runtime/session/manager.go b/internal/agent/runtime/session/manager.go index 808a73bdb..e94779fe6 100644 --- a/internal/agent/runtime/session/manager.go +++ b/internal/agent/runtime/session/manager.go @@ -29,8 +29,8 @@ type Manager struct { // liveness answers "is an owner still alive", which the ledger cannot. Both // backends provide it; only a distributed backend has leases to expire. liveness LivenessBackend - // runs is the durable ledger. A nil ledger means durable admission is not - // wired yet and the manager keeps its pre-ledger behavior. + // runs is required by production Admit. Backend-only tests may omit it + // to exercise live reservations without a durable row. runs ledger.Store // fence is the persistence-ownership cutover applied with each claim. It is // required whenever runs is set: a claim that skipped it would leave a @@ -53,11 +53,11 @@ type Manager struct { mu sync.Mutex controls map[runControlKey]*runControl commandHandler func(context.Context, Command) error - commandReconciler func(context.Context, Command) (bool, error) decisionStore DecisionStore terminalObserver func(context.Context, TerminalRun) decisionFinalizer func(context.Context, RunHandle) error terminalReconciler func(context.Context) error + cancelLostRunDecisions func(context.Context, string, string, string, int64, string) error historyResetHandler HistoryResetHandler pendingCommands map[string]map[*commandWaiter]struct{} inflightCommandTargets map[string]struct{} @@ -95,6 +95,7 @@ type runControl struct { botID string sessionID string runID string + ownerID string turnID string generation string fencingToken int64 @@ -106,6 +107,7 @@ type runControl struct { injectCh chan<- turn.InjectMessage injectMu sync.Mutex injectStopped bool + steerWake chan struct{} // guarded by Manager.mu; never closed converter *chatview.UIMessageStreamConverter leaseStop func() leaseDone chan struct{} @@ -118,6 +120,13 @@ type runControl struct { decisionMu sync.Mutex decisionReady chan struct{} decisionReadyOnce sync.Once + // stepMu guards the step cursor: the highest durable step index whose + // step_end marker this run has consumed into the live projection. Queue + // steer anchoring waits on it so the anchor never precedes output that the + // model loop already produced but the event consumer has not applied yet. + stepMu sync.Mutex + stepConsumed int + stepChanged chan struct{} // pendingDecisions tracks every decision that still awaits a terminal // status. decisionInline marks runtimes that block inside the same turn // instead of parking and re-entering through EventAgentStart. @@ -164,7 +173,7 @@ func (c *runControl) handle() RunHandle { if c == nil { return RunHandle{} } - return RunHandle{BotID: c.botID, SessionID: c.sessionID, RunID: c.runID, TurnID: c.turnID, Generation: c.generation, FencingToken: c.fencingToken} + return RunHandle{BotID: c.botID, SessionID: c.sessionID, RunID: c.runID, OwnerID: c.ownerID, TurnID: c.turnID, Generation: c.generation, FencingToken: c.fencingToken} } func (c *runControl) beginDecisionWait(decisionID string) { @@ -279,6 +288,52 @@ func (c *runControl) decisionReadySignal() <-chan struct{} { return c.decisionReady } +// markStepConsumed records that the live projection now holds every part of +// durable step stepIndex. Waiters blocked in awaitStepConsumed are woken. +func (c *runControl) markStepConsumed(stepIndex int) { + if c == nil { + return + } + c.stepMu.Lock() + defer c.stepMu.Unlock() + if stepIndex+1 > c.stepConsumed { + c.stepConsumed = stepIndex + 1 + } + if c.stepChanged != nil { + close(c.stepChanged) + c.stepChanged = nil + } +} + +// awaitStepConsumed blocks until the projection has consumed step stepIndex, +// the context ends, or the run's lifecycle context ends. Callers only pass an +// index whose step_end marker the native loop has already emitted, so the wait +// is bounded by event consumption, not by model progress. +func (c *runControl) awaitStepConsumed(ctx context.Context, stepIndex int) error { + if c == nil { + return nil + } + for { + c.stepMu.Lock() + if c.stepConsumed > stepIndex { + c.stepMu.Unlock() + return nil + } + if c.stepChanged == nil { + c.stepChanged = make(chan struct{}) + } + changed := c.stepChanged + c.stepMu.Unlock() + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + case <-c.lifecycleCtx.Done(): + return ErrRunOwnershipLost + } + } +} + type Options struct { OwnerID string StateTTL time.Duration @@ -409,18 +464,6 @@ func (m *Manager) SetCommandHandler(handler func(context.Context, Command) error m.mu.Unlock() } -// SetCommandReconciler installs a read-only domain result checker. Unlike the -// owner-local command handler, it may run on any server after the owner or its -// local control disappears. -func (m *Manager) SetCommandReconciler(reconciler func(context.Context, Command) (bool, error)) { - if m == nil { - return - } - m.mu.Lock() - m.commandReconciler = reconciler - m.mu.Unlock() -} - // SetDecisionStore installs the PostgreSQL-backed decision authority used by // every response transport and by waiting-decision recovery. func (m *Manager) SetDecisionStore(store DecisionStore) { @@ -468,6 +511,17 @@ func (m *Manager) SetTerminalReconciler(reconciler func(context.Context) error) m.mu.Unlock() } +// SetLostRunDecisionCanceller installs run-scoped cleanup for decisions parked +// by a run that the reaper has durably marked lost. +func (m *Manager) SetLostRunDecisionCanceller(canceller func(context.Context, string, string, string, int64, string) error) { + if m == nil { + return + } + m.mu.Lock() + m.cancelLostRunDecisions = canceller + m.mu.Unlock() +} + func (m *Manager) observeTerminalRun(ctx context.Context, run TerminalRun) { if m == nil || run.RunID == "" { return @@ -493,22 +547,27 @@ func (m *Manager) reconcileAndObserveTerminalRun(ctx context.Context, run Termin // reconcileTerminalLive repairs the Redis side of a terminal commit that // outlived its owner. Unlike ordinary owner release, this path is allowed after -// lease expiry, but only while the stored run ref still carries the exact -// durable fencing token observed by the reaper. +// lease expiry. The backend atomically verifies the exact durable token against +// the surviving snapshot (or the lease ref for older snapshots). func (m *Manager) reconcileTerminalLive(ctx context.Context, terminal TerminalRun) { if m == nil || m.distributed == nil || terminal.FencingToken <= 0 { return } key := Key{BotID: terminal.BotID, SessionID: terminal.SessionID} - ref, ok, err := m.distributed.LoadRunRef(ctx, key, terminal.RunID) + current, ok, err := m.backend.Load(ctx, key) if err != nil { - m.logger.Warn("load runtime ref for terminal reconciliation failed", slog.Any("error", err), slog.String("run_id", terminal.RunID)) + m.logger.Warn("load runtime snapshot for terminal reconciliation failed", slog.Any("error", err), slog.String("run_id", terminal.RunID)) return } - if !ok || ref.FencingToken != terminal.FencingToken { + if !ok || current.CurrentRunView == nil || current.CurrentRunView.RunID != terminal.RunID { return } + run := current.CurrentRunView status := liveRunStatus(ledger.State(terminal.State)) + if run.Status == status && run.OwnerLeaseExpiresAt == nil && run.ProposedTerminalStatus == "" { + return + } + ref := RunRef{BotID: key.BotID, SessionID: key.SessionID, RunID: run.RunID, OwnerID: run.OwnerID, Generation: run.Generation, FencingToken: terminal.FencingToken} snapshot, changed, err := m.distributed.ReconcileTerminalRun(ctx, key, ref, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { run := snapshot.CurrentRunView if run == nil || run.RunID != ref.RunID || run.Generation != ref.Generation || run.OwnerID != ref.OwnerID { @@ -527,7 +586,6 @@ func (m *Manager) reconcileTerminalLive(ctx context.Context, terminal TerminalRu run.ErrorCode = "" run.Error = "" } - rejectPendingSteerOnRunFinish(run, now) return snapshot, true, nil }) if err != nil { @@ -539,7 +597,7 @@ func (m *Manager) reconcileTerminalLive(ctx context.Context, terminal TerminalRu if !changed { return } - delta := runtimeRunPatch(snapshot, true, true, true, true) + delta := runtimeRunPatch(snapshot, true, true, true) if err := m.publishRuntimeDelta(ctx, snapshot, terminal.RunID, delta); err != nil { m.logger.Warn("publish reconciled runtime terminal failed; subscribers will reload snapshot", slog.Any("error", err), slog.String("run_id", terminal.RunID)) } @@ -746,7 +804,7 @@ func (m *Manager) Start(ctx context.Context) error { } // startReaper is a no-op without a ledger: there is nothing durable to reap, so -// a manager wired for live state only keeps its pre-ledger behavior. +// backend-only tests can isolate the live reservation algorithms. func (m *Manager) startReaper(ctx context.Context) error { if m.runs == nil || m.liveness == nil { return nil @@ -755,6 +813,10 @@ func (m *Manager) startReaper(ctx context.Context) error { reaper.SetWaitingDecisionRecoverer(m.recoverWaitingDecision) reaper.SetTerminalObserver(m.reconcileAndObserveTerminalRun) reaper.SetTerminalReconciler(m.reconcileTerminalRuns) + m.mu.Lock() + cancelLostRunDecisions := m.cancelLostRunDecisions + m.mu.Unlock() + reaper.SetLostRunDecisionCanceller(cancelLostRunDecisions) if err := reaper.Start(ctx); err != nil { return err } @@ -839,49 +901,26 @@ func (m *Manager) shutdown(ctx context.Context) error { return errors.Join(releaseErr, controlErr, reaperErr, backendErr) } -func (m *Manager) StartRun(ctx context.Context, botID, sessionID, runID string, abortCh chan<- struct{}, cancel context.CancelFunc, injectCh chan<- turn.InjectMessage) error { - _, err := m.StartRunHandle(ctx, botID, sessionID, runID, abortCh, cancel, injectCh) - return err -} - -func (m *Manager) StartRunHandle(ctx context.Context, botID, sessionID, runID string, abortCh chan<- struct{}, cancel context.CancelFunc, injectCh chan<- turn.InjectMessage) (RunHandle, error) { - return m.StartRunWithAdmissionBuilderHandle(ctx, botID, sessionID, runID, func(context.Context, RunHandle) (RunAdmissionView, error) { - return RunAdmissionView{}, nil - }, abortCh, cancel, injectCh) -} - -// StartRunWithAdmissionBuilderHandle reserves the cross-server run before -// executing builder, then publishes the running view only after the canonical -// request turn and optional replacement operation are ready. -func (m *Manager) StartRunWithAdmissionBuilderHandle(ctx context.Context, botID, sessionID, runID string, builder func(context.Context, RunHandle) (RunAdmissionView, error), abortCh chan<- struct{}, cancel context.CancelFunc, injectCh chan<- turn.InjectMessage) (RunHandle, error) { - handle, _, err := m.startRun(ctx, runStart{ - botID: botID, - sessionID: sessionID, - runID: runID, - builder: builder, - abortCh: abortCh, - cancel: cancel, - injectCh: injectCh, - }) - return handle, err +// OwnerID returns this manager's stable execution-owner identity. +func (m *Manager) OwnerID() string { + if m == nil { + return "" + } + return m.ownerID } -func (m *Manager) StartRunWithAdmissionBuilderAndOwnershipHandle(ctx context.Context, botID, sessionID, runID string, builder func(context.Context, RunHandle) (RunAdmissionView, error), ownershipCancel context.CancelCauseFunc, abortCh chan<- struct{}, cancel context.CancelFunc, injectCh chan<- turn.InjectMessage) (RunHandle, error) { - handle, _, err := m.startRun(ctx, runStart{ - botID: botID, - sessionID: sessionID, - runID: runID, - builder: builder, - ownershipCancel: ownershipCancel, - abortCh: abortCh, - cancel: cancel, - injectCh: injectCh, - }) - return handle, err +// LivenessGeneration returns the current live-backend incarnation for +// application-owned recovery code. It is read-only; ownership still changes +// only through the durable fenced claim. +func (m *Manager) LivenessGeneration(ctx context.Context) (string, error) { + if m == nil { + return "", ErrManagerClosed + } + return m.livenessGeneration(ctx) } // runStart is one live reservation request. It is a struct rather than a -// parameter list because the ledger path and the pre-ledger entry points differ +// parameter list because durable admission and backend-only test fixtures differ // only in whether they carry a fencing token, and that difference should be // visible at the call site instead of being a positional zero. type runStart struct { @@ -954,7 +993,7 @@ func (m *Manager) startRun(ctx context.Context, start runStart) (RunHandle, Curs ctx = admissionCtx runGeneration := m.newGeneration() - handle := RunHandle{BotID: botID, SessionID: sessionID, RunID: runID, TurnID: start.turnID, Generation: runGeneration, FencingToken: start.fencingToken} + handle := RunHandle{BotID: botID, SessionID: sessionID, RunID: runID, OwnerID: m.ownerID, TurnID: start.turnID, Generation: runGeneration, FencingToken: start.fencingToken} if handle.FencingToken > 0 { ctx = runtimefence.WithContext(ctx, runtimefence.Fence{ BotID: handle.BotID, @@ -967,6 +1006,7 @@ func (m *Manager) startRun(ctx context.Context, start runStart) (RunHandle, Curs botID: botID, sessionID: sessionID, runID: runID, + ownerID: m.ownerID, turnID: start.turnID, generation: runGeneration, fencingToken: start.fencingToken, @@ -1042,6 +1082,7 @@ func (m *Manager) startRun(ctx context.Context, start runStart) (RunHandle, Curs TurnID: start.turnID, InvocationID: start.invocationID, Generation: runGeneration, + FencingToken: start.fencingToken, Status: RunStatusAdmitting, OwnerID: ownerID, OwnerLeaseExpiresAt: leaseExpiresAt, @@ -1169,8 +1210,13 @@ func (m *Manager) startRun(ctx context.Context, start runStart) (RunHandle, Curs snapshot.Seq++ snapshot.UpdatedAt = now run.Status = RunStatusRunning - run.RequestUserTurn = admission.RequestUserTurn run.Operation = admission.Operation + switch { + case admission.RequestUserTurn != nil: + run.UserTurns = []chatview.UITurn{*admission.RequestUserTurn} + case admission.Operation != nil && admission.Operation.ReplacementUserTurn != nil: + run.UserTurns = []chatview.UITurn{*admission.Operation.ReplacementUserTurn} + } run.UpdatedAt = now return snapshot, true, nil }, func(snapshot Snapshot) RuntimeDelta { @@ -1490,17 +1536,6 @@ func (m *Manager) resolveTerminalStatus(ctx context.Context, handle RunHandle, s } } -const steerRunFinishedError = "runtime run finished before steer was applied" - -func rejectPendingSteerOnRunFinish(run *CurrentRunView, now time.Time) { - if run == nil || run.Steer == nil || !isPendingSteerStatus(run.Steer.Status) { - return - } - run.Steer.Status = SteerStatusRejected - run.Steer.Error = steerRunFinishedError - run.Steer.UpdatedAt = now -} - func (m *Manager) finishRunState(ctx context.Context, handle RunHandle, status, errorCode, finishMessage string) (bool, error) { admissionTerminal := false _, changed, err := m.releaseActiveAndPublish(ctx, handle, func(snapshot Snapshot, now time.Time) (Snapshot, bool, error) { @@ -1538,13 +1573,12 @@ func (m *Manager) finishRunState(ctx context.Context, handle RunHandle, status, snapshot.CurrentRunView.OwnerLeaseExpiresAt = nil snapshot.CurrentRunView.ProposedTerminalStatus = "" snapshot.CurrentRunView.FinishProposedAt = nil - rejectPendingSteerOnRunFinish(snapshot.CurrentRunView, now) return snapshot, true, nil }, func(snapshot Snapshot) RuntimeDelta { if admissionTerminal { return RuntimeDelta{CurrentRunView: snapshot.CurrentRunView} } - return runtimeRunPatch(snapshot, true, true, true, m.distributed != nil) + return runtimeRunPatch(snapshot, true, true, m.distributed != nil) }) return changed, err } @@ -1657,7 +1691,19 @@ func (m *Manager) prepareAgentTerminalEvent( return agentTerminalProposal{}, nil } if prepared.State.Terminal() { - return agentTerminalProposal{}, ErrRunOwnershipLost + // A repeated terminal event can meet an already-finalized ledger row. + // prepareLedgerFinish verified this handle's fence; only the same + // durable outcome may be replayed into the live projection. + if prepared.State != terminalLedgerState(status, errorCode, "") { + return agentTerminalProposal{}, ErrRunOwnershipLost + } + return agentTerminalProposal{ + prepared: true, + status: liveRunStatus(prepared.State), + errorCode: strings.TrimSpace(prepared.ErrorCode), + error: strings.TrimSpace(prepared.ErrorMessage), + at: prepared.FinishProposedAt, + }, nil } if prepared.State == ledger.StateFinishing { status = liveRunStatus(prepared.ProposedState) @@ -1746,6 +1792,12 @@ func (m *Manager) handleAgentEvent(ctx context.Context, handle RunHandle, event default: messages = ctrl.converter.HandleEvent(chatview.UIStreamEventFromAgentEvent(event)) } + if event.Type == native.EventStepEnd { + // The marker itself changes nothing visible; it only advances the step + // cursor that queue steer anchoring waits on. + ctrl.markStepConsumed(event.StepNumber) + return nil, nil + } delta, visibleChange := runtimeDeltaForAgentEvent(event, messages) if !visibleChange { return messages, nil @@ -1817,7 +1869,6 @@ func (m *Manager) handleAgentEvent(ctx context.Context, handle RunHandle, event proposedAt = now } run.FinishProposedAt = &proposedAt - rejectPendingSteerOnRunFinish(run, now) case native.EventAgentAbort: if !terminalProposal.prepared { return snapshot, true, nil @@ -1829,7 +1880,6 @@ func (m *Manager) handleAgentEvent(ctx context.Context, handle RunHandle, event proposedAt = now } run.FinishProposedAt = &proposedAt - rejectPendingSteerOnRunFinish(run, now) case native.EventError: run.ErrorCode = strings.TrimSpace(event.Code) run.Error = strings.TrimSpace(event.Error) @@ -1841,11 +1891,11 @@ func (m *Manager) handleAgentEvent(ctx context.Context, handle RunHandle, event }, func(snapshot Snapshot) RuntimeDelta { switch event.Type { case native.EventAgentEnd, native.EventAgentAbort: - delta.Run = runtimeRunPatch(snapshot, true, terminalProposal.prepared, terminalProposal.prepared, false).Run + delta.Run = runtimeRunPatch(snapshot, true, terminalProposal.prepared, false).Run case native.EventAgentStart, native.EventToolApprovalRequest, native.EventUserInputRequest: - delta.Run = runtimeRunPatch(snapshot, true, false, false, false).Run + delta.Run = runtimeRunPatch(snapshot, true, false, false).Run case native.EventError, native.EventRetry: - delta.Run = runtimeRunPatch(snapshot, false, true, false, false).Run + delta.Run = runtimeRunPatch(snapshot, false, true, false).Run } return delta }) @@ -1947,7 +1997,10 @@ func (m *Manager) liveSnapshot(ctx context.Context, botID, sessionID string) (Sn return Snapshot{}, err } } - if m.distributed != nil { + // Durable runtimes have one terminal authority: the ledger/reaper protocol. + // A read-side expiry must not publish lost or delete the run ref before + // the reaper reconciles an already-committed finishing proposal. + if m.distributed != nil && m.runs == nil { now, err := m.backend.Now(ctx) if err != nil { return Snapshot{}, fmt.Errorf("load runtime backend time: %w", err) @@ -1965,7 +2018,7 @@ func (m *Manager) liveSnapshot(ctx context.Context, botID, sessionID string) (Sn } return current, true, nil }, func(snapshot Snapshot) RuntimeDelta { - return runtimeRunPatch(snapshot, true, true, false, true) + return runtimeRunPatch(snapshot, true, true, true) }) if err != nil { return Snapshot{}, err @@ -2018,6 +2071,7 @@ func (m *Manager) hydrateSnapshotFromLedger(ctx context.Context, snapshot Snapsh TurnID: run.TurnID, InvocationID: run.InvocationID, Generation: run.LiveGeneration, + FencingToken: run.FencingToken, Status: liveRunStatus(run.State), OwnerID: run.OwnerID, StartedAt: run.CreatedAt, diff --git a/internal/agent/runtime/session/manager_test.go b/internal/agent/runtime/session/manager_test.go index e1c3d7599..b20c1417e 100644 --- a/internal/agent/runtime/session/manager_test.go +++ b/internal/agent/runtime/session/manager_test.go @@ -948,7 +948,7 @@ func runManagerAbortAcknowledgesReservedRunBeforeTerminalCompletion(t *testing.T manager := testRuntimeManagerWithOptions(t, backend, Options{ OwnerID: "owner-start-abort", StateTTL: time.Hour, - OwnerLeaseTTL: 60 * time.Millisecond, + OwnerLeaseTTL: time.Second, // This scenario keeps ownership while admission is gated. CommandAckTTL: 50 * time.Millisecond, }) runCtx, cancelRun := context.WithCancel(context.Background()) @@ -1259,18 +1259,6 @@ func runCommonRuntimeManagerContract(t *testing.T, suite runtimeBackendContractS t.Parallel() runRuntimeManagerSharesRequestUserTurnContract(t, suite) }) - t.Run("keeps queued steer valid past command acknowledgement timeout", func(t *testing.T) { - t.Parallel() - runRuntimeManagerKeepsQueuedSteerPastAckTimeoutContract(t, suite) - }) - t.Run("rejects queued steer when the run finishes", func(t *testing.T) { - t.Parallel() - runRuntimeManagerRejectsQueuedSteerOnFinishContract(t, suite) - }) - t.Run("rejects queued steer when the agent sends a terminal event", func(t *testing.T) { - t.Parallel() - runRuntimeManagerRejectsQueuedSteerOnAgentTerminalContract(t, suite) - }) t.Run("recovers subscriber buffer overflow", func(t *testing.T) { t.Parallel() runRuntimeManagerSignalsSubscriberOverflowContract(t, suite) @@ -1425,19 +1413,6 @@ func runRuntimeManagerFencesDelayedOwnerMutationsContract(t *testing.T, suite ru if err != nil { t.Fatalf("start first generation: %v", err) } - if _, err := manager.Steer(context.Background(), testBotID, testSessionID, testRunID, "old generation steer"); err != nil { - t.Fatalf("steer first generation: %v", err) - } - var delayedApplied func() - select { - case injected := <-oldInject: - delayedApplied = injected.Applied - case <-time.After(time.Second): - t.Fatal("first generation steer was not delivered") - } - if delayedApplied == nil { - t.Fatal("first generation steer has no applied callback") - } if err := manager.FinishRun(context.Background(), oldHandle, RunStatusCompleted, ""); err != nil { t.Fatalf("finish first generation: %v", err) } @@ -1453,9 +1428,6 @@ func runRuntimeManagerFencesDelayedOwnerMutationsContract(t *testing.T, suite ru if ok, err := manager.AbortRun(context.Background(), oldHandle); ok || !errors.Is(err, ErrRunOwnershipLost) { t.Fatalf("stale abort = ok:%v err:%v, want ErrRunOwnershipLost", ok, err) } - if _, err := manager.SteerRun(context.Background(), oldHandle, "late steer command"); !errors.Is(err, ErrRunOwnershipLost) { - t.Fatalf("stale steer error = %v, want ErrRunOwnershipLost", err) - } select { case <-newAbort: t.Fatal("stale abort signaled the new generation") @@ -1470,7 +1442,6 @@ func runRuntimeManagerFencesDelayedOwnerMutationsContract(t *testing.T, suite ru if err := manager.FinishRun(context.Background(), oldHandle, RunStatusErrored, "late finish"); !errors.Is(err, ErrRunOwnershipLost) { t.Fatalf("late finish error = %v, want ErrRunOwnershipLost", err) } - delayedApplied() if manager.localControlForHandle(newHandle) == nil { t.Fatal("late mutation removed the current run control") } @@ -1486,13 +1457,15 @@ func runRuntimeManagerFencesDelayedOwnerMutationsContract(t *testing.T, suite ru func runDistributedRuntimeManagerContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { t.Helper() + t.Run("repairs terminal after lease expiry using exact receipt", func(t *testing.T) { runExpiredLeaseTerminalReceiptContract(t, suite) }) + t.Run("routes durable decisions without UI projection", func(t *testing.T) { runDistributedDecisionRouteContract(t, suite) }) t.Run("does not prepare rejected run operations", func(t *testing.T) { t.Parallel() runRuntimeManagerDoesNotBuildRejectedOperationContract(t, suite) }) - t.Run("routes abort and steer across managers", func(t *testing.T) { + t.Run("routes abort across managers", func(t *testing.T) { t.Parallel() - runRuntimeManagerRoutesAbortAndSteerAcrossManagersContract(t, suite) + runRuntimeManagerRoutesAbortAcrossManagersContract(t, suite) }) t.Run("routes abort past a stale local generation", func(t *testing.T) { t.Parallel() @@ -1534,10 +1507,6 @@ func runDistributedRuntimeManagerContract(t *testing.T, suite distributedRuntime t.Parallel() runRuntimeManagerAcknowledgesAppliedResponseAfterFinish(t, suite) }) - t.Run("reconciles a response after owner control is lost", func(t *testing.T) { - t.Parallel() - runRuntimeManagerReconcilesResponseAfterOwnerControlLoss(t, suite) - }) t.Run("keeps command routing alive after startup context cancellation", func(t *testing.T) { t.Parallel() runRuntimeManagerCommandRoutingOutlivesStartContext(t, suite) @@ -1734,12 +1703,12 @@ func runRuntimeManagerHandsExhaustedDurableFinishToReaper(t *testing.T, suite di transient := errors.New("database remains unavailable") if phase == "prepare" { - runs.setPrepareErr(transient) + runs.SetPrepareErr(transient) } else { if _, err := manager.HandleAgentEvent(context.Background(), admission.Handle, native.StreamEvent{Type: native.EventAgentEnd}); err != nil { t.Fatalf("prepare terminal event: %v", err) } - runs.setFinalizeErr(transient) + runs.SetFinalizeErr(transient) } if err := manager.FinishRun(context.Background(), admission.Handle, RunStatusCompleted, ""); err == nil { t.Fatal("FinishRun() error = nil, want initial durable failure") @@ -1752,8 +1721,8 @@ func runRuntimeManagerHandsExhaustedDurableFinishToReaper(t *testing.T, suite di if manager.localControlForHandle(admission.Handle) != nil { t.Fatal("durable retry budget expired without releasing local control") } - runs.setPrepareErr(nil) - runs.setFinalizeErr(nil) + runs.SetPrepareErr(nil) + runs.SetFinalizeErr(nil) want := ledger.StateLost if phase == "finalize" { @@ -1761,52 +1730,16 @@ func runRuntimeManagerHandsExhaustedDurableFinishToReaper(t *testing.T, suite di } reaperDeadline := time.Now().Add(2 * time.Second) for time.Now().Before(reaperDeadline) { - if got := runs.state(admission.RunID); got == want { + if got := runs.State(admission.RunID); got == want { return } time.Sleep(10 * time.Millisecond) } - t.Fatalf("reaper state = %q, want %q after %s retry timeout", runs.state(admission.RunID), want, phase) + t.Fatalf("reaper state = %q, want %q after %s retry timeout", runs.State(admission.RunID), want, phase) }) } } -func runRuntimeManagerReconcilesResponseAfterOwnerControlLoss(t *testing.T, suite distributedRuntimeBackendContractSuite) { - t.Helper() - backends := suite.newSharedBackends(t, 2) - owner := testRuntimeManager(t, backends[0], "response-reconcile-owner") - restarted := testRuntimeManager(t, backends[1], "response-reconcile-owner") - if err := owner.StartRun(context.Background(), testBotID, testSessionID, "stream-response-reconcile", make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1)); err != nil { - t.Fatalf("start response run: %v", err) - } - handle := requireRunHandle(t, owner, testBotID, testSessionID, "stream-response-reconcile") - if _, err := owner.HandleAgentEvent(context.Background(), handle, native.StreamEvent{ - Type: native.EventToolApprovalRequest, ToolName: "exec", ToolCallID: "call-response-reconcile", - ApprovalID: "approval-response-reconcile", Status: "pending", - }); err != nil { - t.Fatalf("record response target: %v", err) - } - owner.forgetLocalControlForHandle(context.Background(), handle) - var reconciled atomic.Int64 - restarted.SetCommandReconciler(func(_ context.Context, command Command) (bool, error) { - reconciled.Add(1) - if command.TargetID != "approval-response-reconcile" || command.Generation != handle.Generation { - t.Fatalf("reconcile command = %#v", command) - } - return true, nil - }) - handled, err := restarted.DispatchActiveCommand( - context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, - "approval-response-reconcile", []byte(`{"decision":"approve"}`), - ) - if err != nil || !handled { - t.Fatalf("reconciled response = handled:%v err:%v", handled, err) - } - if reconciled.Load() != 1 { - t.Fatalf("reconciler calls = %d, want 1", reconciled.Load()) - } -} - func runManagerAbortBeforeClaim(t *testing.T, suite distributedRuntimeBackendContractSuite) { t.Helper() backend := &preClaimGateBackend{ @@ -2292,7 +2225,7 @@ func runRuntimeManagerRejectsDelayedOldGenerationCommand(t *testing.T, suite dis func runRuntimeManagerRejectsExpiredLeaseRevivalContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { t.Helper() - const leaseTTL = 100 * time.Millisecond + const leaseTTL = 500 * time.Millisecond backend := suite.newBackend(t) manager := testRuntimeManagerWithOptions(t, backend, Options{ OwnerID: "owner-expired-revival", @@ -2352,57 +2285,6 @@ func runRuntimeManagerRejectsExpiredLeaseRevivalContract(t *testing.T, suite dis } } -func runRuntimeManagerKeepsQueuedSteerPastAckTimeoutContract(t *testing.T, suite runtimeBackendContractSuite) { - t.Helper() - - const commandAckTTL = 500 * time.Millisecond - manager := testRuntimeManagerWithOptions(t, suite.newBackend(t), Options{ - OwnerID: "owner-queued-steer", - StateTTL: time.Hour, - OwnerLeaseTTL: time.Second, - CommandAckTTL: commandAckTTL, - }) - injectCh := make(chan turn.InjectMessage, 1) - if err := manager.StartRun(context.Background(), testBotID, testSessionID, testRunID, make(chan struct{}, 1), func() {}, injectCh); err != nil { - t.Fatalf("start run: %v", err) - } - steer, err := manager.Steer(context.Background(), testBotID, testSessionID, testRunID, "wait for the next model step") - if err != nil { - t.Fatalf("steer: %v", err) - } - - queued := waitRuntimeSnapshot(t, manager, testBotID, testSessionID, func(snapshot Snapshot) bool { - return snapshot.CurrentRunView != nil && - snapshot.CurrentRunView.Steer != nil && - snapshot.CurrentRunView.Steer.ID == steer.ID && - snapshot.CurrentRunView.Steer.Status == SteerStatusQueued - }) - if queued.CurrentRunView == nil || queued.CurrentRunView.Steer == nil { - t.Fatal("queued steer is missing") - } - time.Sleep(2 * commandAckTTL) - snapshot, err := manager.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot queued steer after acknowledgement timeout: %v", err) - } - if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Steer == nil || snapshot.CurrentRunView.Steer.ID != steer.ID || snapshot.CurrentRunView.Steer.Status != SteerStatusQueued { - t.Fatalf("steer after acknowledgement timeout = %#v, want queued", snapshot.CurrentRunView) - } - - select { - case injected := <-injectCh: - if injected.Applied == nil { - t.Fatal("queued steer is missing its applied acknowledgement") - } - injected.Applied() - case <-time.After(time.Second): - t.Fatal("queued steer was not delivered to the agent") - } - waitRuntimeSnapshot(t, manager, testBotID, testSessionID, func(snapshot Snapshot) bool { - return snapshot.CurrentRunView != nil && snapshot.CurrentRunView.Steer != nil && snapshot.CurrentRunView.Steer.Status == SteerStatusApplied - }) -} - func runRuntimeManagerCancelsExecutionAfterOwnershipLossContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { t.Helper() @@ -2712,122 +2594,6 @@ func TestRuntimeManagerBuilderFailureReleasesReservation(t *testing.T) { } } -func runRuntimeManagerRejectsQueuedSteerOnFinishContract(t *testing.T, suite runtimeBackendContractSuite) { - t.Helper() - - manager := testRuntimeManager(t, suite.newBackend(t), "owner-finished-steer") - injectCh := make(chan turn.InjectMessage, 1) - if err := manager.StartRun(context.Background(), testBotID, testSessionID, testRunID, make(chan struct{}, 1), func() {}, injectCh); err != nil { - t.Fatalf("start run: %v", err) - } - steer, err := manager.Steer(context.Background(), testBotID, testSessionID, testRunID, "adjust before finish") - if err != nil { - t.Fatalf("steer: %v", err) - } - waitRuntimeSnapshot(t, manager, testBotID, testSessionID, func(snapshot Snapshot) bool { - return snapshot.CurrentRunView != nil && snapshot.CurrentRunView.Steer != nil && - snapshot.CurrentRunView.Steer.ID == steer.ID && snapshot.CurrentRunView.Steer.Status == SteerStatusQueued - }) - if err := manager.FinishRun(context.Background(), requireRunHandle(t, manager, testBotID, testSessionID, testRunID), RunStatusCompleted, ""); err != nil { - t.Fatalf("finish run: %v", err) - } - snapshot, err := manager.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot: %v", err) - } - if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Steer == nil || snapshot.CurrentRunView.Steer.Status != SteerStatusRejected { - t.Fatalf("finished steer = %#v, want rejected", snapshot.CurrentRunView) - } - if snapshot.CurrentRunView.Steer.Error != steerRunFinishedError { - t.Fatalf("finished steer error = %q", snapshot.CurrentRunView.Steer.Error) - } - select { - case injected := <-injectCh: - if injected.Applied != nil { - injected.Applied() - } - default: - t.Fatal("queued steer was not delivered") - } - snapshot, err = manager.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot after late apply: %v", err) - } - if snapshot.CurrentRunView.Steer.Status != SteerStatusRejected { - t.Fatalf("late apply changed terminal steer = %#v", snapshot.CurrentRunView.Steer) - } -} - -func runRuntimeManagerRejectsQueuedSteerOnAgentTerminalContract(t *testing.T, suite runtimeBackendContractSuite) { - t.Helper() - - for _, tc := range []struct { - name string - event native.StreamEvent - wantStatus string - }{ - {name: "end", event: native.StreamEvent{Type: native.EventAgentEnd}, wantStatus: RunStatusCompleted}, - {name: "abort", event: native.StreamEvent{Type: native.EventAgentAbort}, wantStatus: RunStatusAborted}, - } { - t.Run(tc.name, func(t *testing.T) { - manager := testRuntimeManager(t, suite.newBackend(t), "owner-agent-terminal-steer-"+tc.name) - sub, err := manager.Subscribe(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("subscribe: %v", err) - } - defer sub.Close() - injectCh := make(chan turn.InjectMessage, 1) - handle, err := manager.StartRunHandle(context.Background(), testBotID, testSessionID, testRunID, make(chan struct{}, 1), func() {}, injectCh) - if err != nil { - t.Fatalf("start run: %v", err) - } - steer, err := manager.SteerRun(context.Background(), handle, "adjust before agent "+tc.name) - if err != nil { - t.Fatalf("steer: %v", err) - } - select { - case <-injectCh: - case <-time.After(time.Second): - t.Fatal("queued steer was not delivered") - } - waitRuntimeSnapshot(t, manager, testBotID, testSessionID, func(snapshot Snapshot) bool { - return snapshot.CurrentRunView != nil && snapshot.CurrentRunView.Steer != nil && - snapshot.CurrentRunView.Steer.ID == steer.ID && snapshot.CurrentRunView.Steer.Status == SteerStatusQueued - }) - if _, err := manager.HandleAgentEvent(context.Background(), handle, tc.event); err != nil { - t.Fatalf("handle agent %s: %v", tc.name, err) - } - - snapshot, err := manager.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot: %v", err) - } - if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Steer == nil || snapshot.CurrentRunView.Steer.Status != SteerStatusRejected { - t.Fatalf("agent-terminal steer = %#v, want rejected", snapshot.CurrentRunView) - } - if snapshot.CurrentRunView.Steer.Error != steerRunFinishedError { - t.Fatalf("agent-terminal steer error = %q", snapshot.CurrentRunView.Steer.Error) - } - event := waitRuntimeEvent(t, sub.C, func(event Event) bool { - return event.Delta != nil && event.Delta.Run != nil && event.Delta.Run.Status != nil && *event.Delta.Run.Status == RunStatusFinishing - }) - if event.Delta.Run.Steer == nil || event.Delta.Run.Steer.Status != SteerStatusRejected { - t.Fatalf("agent-terminal delta = %#v, want rejected steer", event.Delta) - } - if snapshot.CurrentRunView.ProposedTerminalStatus != tc.wantStatus || snapshot.CurrentRunView.FinishProposedAt == nil { - t.Fatalf("agent-terminal proposal = %#v, want %s", snapshot.CurrentRunView, tc.wantStatus) - } - if err := manager.FinishRun(context.Background(), handle, "", ""); err != nil { - t.Fatalf("finalize agent %s: %v", tc.name, err) - } - snapshot, err = manager.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil || snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Status != tc.wantStatus { - t.Fatalf("final agent status = %#v, err %v, want %s", snapshot.CurrentRunView, err, tc.wantStatus) - } - }) - } -} - func runRuntimeManagerSharesReplacementOperationContract(t *testing.T, suite runtimeBackendContractSuite) { t.Helper() @@ -2906,20 +2672,20 @@ func runRuntimeManagerSharesRequestUserTurnContract(t *testing.T, suite runtimeB t.Fatalf("observer snapshot: %v", err) } got := snapshot.CurrentRunView - if got == nil || got.RequestUserTurn == nil { + if got == nil || got.requestUserTurn() == nil { t.Fatalf("current run request user turn = %#v", got) } - if got.RequestUserTurn.Text != requestTurn.Text || got.RequestUserTurn.ExternalMessageID != testRunID { - t.Fatalf("request user turn = %#v", got.RequestUserTurn) + if got.requestUserTurn().Text != requestTurn.Text || got.requestUserTurn().ExternalMessageID != testRunID { + t.Fatalf("request user turn = %#v", got.requestUserTurn()) } - if len(got.RequestUserTurn.Attachments) != 1 || got.RequestUserTurn.Attachments[0].ContentHash != "sha256:notes" { - t.Fatalf("request user turn attachments = %#v", got.RequestUserTurn.Attachments) + if len(got.requestUserTurn().Attachments) != 1 || got.requestUserTurn().Attachments[0].ContentHash != "sha256:notes" { + t.Fatalf("request user turn attachments = %#v", got.requestUserTurn().Attachments) } requestTurn.Text = "mutated by caller" requestTurn.Attachments[0].Name = "mutated.txt" - if got.RequestUserTurn.Text != "inspect the workspace" || got.RequestUserTurn.Attachments[0].Name != "notes.txt" { - t.Fatalf("runtime request user turn aliases caller state: %#v", got.RequestUserTurn) + if got.requestUserTurn().Text != "inspect the workspace" || got.requestUserTurn().Attachments[0].Name != "notes.txt" { + t.Fatalf("runtime request user turn aliases caller state: %#v", got.requestUserTurn()) } } @@ -3132,7 +2898,7 @@ func TestRuntimeManagerDropsEpochlessEventsAfterEpochIsEstablished(t *testing.T) } } -func runRuntimeManagerRoutesAbortAndSteerAcrossManagersContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { +func runRuntimeManagerRoutesAbortAcrossManagersContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { t.Helper() backends := suite.newSharedBackends(t, 2) @@ -3177,47 +2943,6 @@ func runRuntimeManagerRoutesAbortAndSteerAcrossManagersContract(t *testing.T, su if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Status != RunStatusAborted { t.Fatalf("abort status changed after errored finish: %#v", snapshot.CurrentRunView) } - - steerInjectCh := make(chan turn.InjectMessage, 1) - if err := owner.StartRun(context.Background(), testBotID, testSessionID, "stream-steer", make(chan struct{}, 1), func() {}, steerInjectCh); err != nil { - t.Fatalf("start steer run: %v", err) - } - steer, err := remote.Steer(context.Background(), testBotID, testSessionID, "stream-steer", "adjust course") - if err != nil { - t.Fatalf("steer: %v", err) - } - if steer.Status != SteerStatusPending { - t.Fatalf("initial steer = %#v", steer) - } - select { - case injected := <-steerInjectCh: - if injected.Text != "adjust course" { - t.Fatalf("injected text = %q", injected.Text) - } - pending, err := remote.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot pending steer: %v", err) - } - if pending.CurrentRunView == nil || pending.CurrentRunView.Steer == nil || pending.CurrentRunView.Steer.Status != SteerStatusQueued { - t.Fatalf("steer was not acknowledged as queued before agent consumption: %#v", pending.CurrentRunView) - } - if _, err := remote.Steer(context.Background(), testBotID, testSessionID, "stream-steer", "overlapping adjustment"); err == nil { - t.Fatal("concurrent steer should be rejected while the first command is pending") - } - if injected.Applied == nil { - t.Fatal("steer injection is missing its agent-consumption acknowledgement") - } - injected.Applied() - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for steer injection") - } - - snapshot = waitRuntimeSnapshot(t, remote, testBotID, testSessionID, func(s Snapshot) bool { - return s.CurrentRunView != nil && s.CurrentRunView.Steer != nil && s.CurrentRunView.Steer.Status == SteerStatusApplied - }) - if snapshot.CurrentRunView.Steer.ID == "" { - t.Fatalf("steer state = %#v", snapshot.CurrentRunView.Steer) - } } func runRuntimeManagerRoutesActiveResponsesAcrossManagersContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { @@ -3250,7 +2975,7 @@ func runRuntimeManagerRoutesActiveResponsesAcrossManagersContract(t *testing.T, {commandType: CommandToolApprovalResponse, targetID: "approval-1"}, {commandType: CommandUserInputResponse, targetID: "input-1"}, } { - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, testSessionID, request.commandType, request.targetID, []byte(`{"ok":true}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, testSessionID, request.commandType, request.targetID, []byte(`{"ok":true}`)) if err != nil || !handled { t.Fatalf("dispatch %s = handled:%v err:%v", request.commandType, handled, err) } @@ -3263,7 +2988,7 @@ func runRuntimeManagerRoutesActiveResponsesAcrossManagersContract(t *testing.T, t.Fatalf("timed out waiting for %s", request.commandType) } } - if handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-old", nil); err != nil || handled { + if handled, err := remote.dispatchTestCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-old", nil); err != nil || handled { t.Fatalf("unrelated target = handled:%v err:%v", handled, err) } } @@ -3287,7 +3012,7 @@ func runRuntimeManagerPreservesRemoteCommandDeadlineError(t *testing.T, suite di t.Fatalf("record deadline approval: %v", err) } - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, "session-deadline", CommandToolApprovalResponse, "approval-deadline", []byte(`{"action":"approve"}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, "session-deadline", CommandToolApprovalResponse, "approval-deadline", []byte(`{"action":"approve"}`)) if !handled || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("remote deadline result = handled:%v err:%v, want context.DeadlineExceeded", handled, err) } @@ -3316,7 +3041,7 @@ func runRuntimeManagerPreservesOwnershipDeadlineError(t *testing.T, suite distri } ownerBackend.failing.Store(true) - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, "session-ownership-deadline", CommandToolApprovalResponse, "approval-ownership-deadline", []byte(`{"action":"approve"}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, "session-ownership-deadline", CommandToolApprovalResponse, "approval-ownership-deadline", []byte(`{"action":"approve"}`)) if !handled || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("ownership deadline result = handled:%v err:%v, want context.DeadlineExceeded", handled, err) } @@ -3345,7 +3070,7 @@ func runRuntimeManagerAcknowledgesAppliedResponseAfterFinish(t *testing.T, suite t.Fatalf("record approval request: %v", err) } - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-applied", []byte(`{"action":"approve"}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-applied", []byte(`{"action":"approve"}`)) if err != nil || !handled { t.Fatalf("applied response = handled:%v err:%v, want acknowledged success", handled, err) } @@ -3415,7 +3140,7 @@ func runRuntimeManagerCancelsActiveResponseOnFinish(t *testing.T, suite distribu } dispatchDone := make(chan dispatchResult, 1) go func() { - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-finish", []byte(`{"action":"approve"}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, testSessionID, CommandToolApprovalResponse, "approval-finish", []byte(`{"action":"approve"}`)) dispatchDone <- dispatchResult{handled: handled, err: err} }() receiveTestResult(t, "active response handler start", handlerStarted) @@ -3468,7 +3193,7 @@ func runRuntimeManagerExpiresActiveResponseHandlers(t *testing.T, suite distribu {commandType: CommandToolApprovalResponse, targetID: "approval-expiry"}, {commandType: CommandUserInputResponse, targetID: "input-expiry"}, } { - handled, err := remote.DispatchActiveCommand(context.Background(), testBotID, "session-response-expiry", request.commandType, request.targetID, []byte(`{"ok":true}`)) + handled, err := remote.dispatchTestCommand(context.Background(), testBotID, "session-response-expiry", request.commandType, request.targetID, []byte(`{"ok":true}`)) if !handled || err == nil { t.Fatalf("expiring %s dispatch = handled:%v err:%v, want deadline error", request.commandType, handled, err) } @@ -3513,7 +3238,7 @@ func runRuntimeManagerCancelsActiveResponseOnClose(t *testing.T, suite distribut } dispatchDone := make(chan dispatchResult, 1) go func() { - handled, err := manager.DispatchActiveCommand(context.Background(), testBotID, "session-response-close", CommandToolApprovalResponse, "approval-close", []byte(`{"action":"approve"}`)) + handled, err := manager.dispatchTestCommand(context.Background(), testBotID, "session-response-close", CommandToolApprovalResponse, "approval-close", []byte(`{"action":"approve"}`)) dispatchDone <- dispatchResult{handled: handled, err: err} }() receiveTestResult(t, "active response handler start", handlerStarted) @@ -3901,45 +3626,6 @@ func runRuntimeManagerDroppedCommandAckContract(t *testing.T, suite distributedR if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Status != RunStatusRunning { t.Fatalf("dropped abort status = %#v, want still running", snapshot.CurrentRunView) } - - steer, err := remote.Steer(context.Background(), testBotID, testSessionID, testRunID, "adjust course") - if err != nil { - t.Fatalf("dropped steer initial publish: %v", err) - } - if steer.Status != SteerStatusPending { - t.Fatalf("initial dropped steer = %#v", steer) - } - snapshot = waitRuntimeSnapshot(t, remote, testBotID, testSessionID, func(s Snapshot) bool { - return s.CurrentRunView != nil && - s.CurrentRunView.Steer != nil && - s.CurrentRunView.Steer.ID == steer.ID && - s.CurrentRunView.Steer.Status == SteerStatusRejected - }) - if snapshot.CurrentRunView.Steer.Error != "runtime steer command was not acknowledged" { - t.Fatalf("dropped steer state = %#v", snapshot.CurrentRunView.Steer) - } - - owner.applyCommand(context.Background(), Command{ - Type: CommandSteer, - BotID: testBotID, - SessionID: testSessionID, - RunID: testRunID, - SteerID: steer.ID, - Text: "late adjust course", - CreatedAt: time.Now().UTC().Add(-time.Second), - }) - select { - case injected := <-injectCh: - t.Fatalf("late rejected steer was injected: %#v", injected) - default: - } - snapshot, err = remote.Snapshot(context.Background(), testBotID, testSessionID) - if err != nil { - t.Fatalf("snapshot after late steer command: %v", err) - } - if snapshot.CurrentRunView == nil || snapshot.CurrentRunView.Steer == nil || snapshot.CurrentRunView.Steer.Status != SteerStatusRejected { - t.Fatalf("late steer command state = %#v, want rejected", snapshot.CurrentRunView) - } } func runRuntimeManagerRetriesDroppedCommandContract(t *testing.T, suite distributedRuntimeBackendContractSuite) { @@ -4008,7 +3694,7 @@ func runRuntimeManagerReleasesPendingCommandOnClose(t *testing.T, suite distribu } dispatchDone := make(chan dispatchResult, 1) go func() { - handled, dispatchErr := remote.DispatchActiveCommand( + handled, dispatchErr := remote.dispatchTestCommand( dispatchCtx, testBotID, testSessionID, CommandToolApprovalResponse, "approval-pending-close", json.RawMessage(`{"status":"approved"}`), ) @@ -4095,7 +3781,7 @@ func runRuntimeManagerPreservesPreparedOutcomeOnClose(t *testing.T, suite distri if _, err := owner.HandleAgentEvent(context.Background(), admission.Handle, native.StreamEvent{Type: native.EventAgentEnd}); err != nil { t.Fatalf("prepare terminal event: %v", err) } - if got := runs.state(admission.RunID); got != ledger.StateFinishing { + if got := runs.State(admission.RunID); got != ledger.StateFinishing { t.Fatalf("ledger before close = %q, want finishing", got) } const activeSessionID = "session-running-close" @@ -4123,10 +3809,10 @@ func runRuntimeManagerPreservesPreparedOutcomeOnClose(t *testing.T, suite distri t.Fatalf("close owner: %v", err) } closed = true - if got := runs.state(admission.RunID); got != ledger.StateCompleted { + if got := runs.State(admission.RunID); got != ledger.StateCompleted { t.Fatalf("ledger after close = %q, want completed", got) } - if got := runs.state(activeAdmission.RunID); got != ledger.StateLost { + if got := runs.State(activeAdmission.RunID); got != ledger.StateLost { t.Fatalf("active ledger after close = %q, want lost", got) } receiveTestResult(t, "active run cancellation", activeCanceled) @@ -4194,12 +3880,12 @@ func runRuntimeManagerDoesNotBlockCommandResultsBehindSlowHandlers(t *testing.T, blockedDispatchDone := make(chan error, 1) go func() { - _, err := ownerB.DispatchActiveCommand(context.Background(), testBotID, "session-command-hol-a", CommandToolApprovalResponse, "approval-command-hol-a", []byte(`{"decision":"approve"}`)) + _, err := ownerB.dispatchTestCommand(context.Background(), testBotID, "session-command-hol-a", CommandToolApprovalResponse, "approval-command-hol-a", []byte(`{"decision":"approve"}`)) blockedDispatchDone <- err }() receiveTestResult(t, "blocked owner command handler", blockedHandlerEntered) - handled, err := ownerA.DispatchActiveCommand(context.Background(), testBotID, "session-command-hol-b", CommandToolApprovalResponse, "approval-command-hol-b", []byte(`{"decision":"approve"}`)) + handled, err := ownerA.dispatchTestCommand(context.Background(), testBotID, "session-command-hol-b", CommandToolApprovalResponse, "approval-command-hol-b", []byte(`{"decision":"approve"}`)) if err != nil || !handled { t.Fatalf("unrelated command behind blocked handler = handled:%v err:%v", handled, err) } diff --git a/internal/agent/runtime/session/memory.go b/internal/agent/runtime/session/memory.go index b588e79a5..bd64c2362 100644 --- a/internal/agent/runtime/session/memory.go +++ b/internal/agent/runtime/session/memory.go @@ -87,6 +87,8 @@ type MemoryBackend struct { decisionOutputs map[string]*memoryDecisionOutput decisionOutputExpiresAt map[string]time.Time historyResets map[string]ResetLease + steerQueues map[string]steerQueueState + followUpQueues map[string]followUpQueueState subscribers *subscriberSet[Event] closed bool // generation is this process's liveness incarnation. It is minted once and @@ -109,6 +111,8 @@ func NewMemoryBackendWithTTL(stateTTL time.Duration) *MemoryBackend { decisionOutputs: make(map[string]*memoryDecisionOutput), decisionOutputExpiresAt: make(map[string]time.Time), historyResets: make(map[string]ResetLease), + steerQueues: make(map[string]steerQueueState), + followUpQueues: make(map[string]followUpQueueState), subscribers: newSubscriberSet[Event](), generation: uuid.NewString(), } @@ -418,8 +422,12 @@ func cloneSnapshot(snapshot Snapshot) (Snapshot, error) { currentRun.Messages = nil snapshot.CurrentRunView = ¤tRun } + data, err := marshalSnapshot(snapshot) + if err != nil { + return Snapshot{}, err + } var out Snapshot - if err := cloneJSON(snapshot, &out); err != nil { + if err := unmarshalSnapshot(data, &out); err != nil { return Snapshot{}, err } if out.CurrentRunView != nil { diff --git a/internal/agent/runtime/session/memory_live_queue.go b/internal/agent/runtime/session/memory_live_queue.go new file mode 100644 index 000000000..b392e9548 --- /dev/null +++ b/internal/agent/runtime/session/memory_live_queue.go @@ -0,0 +1,431 @@ +package sessionruntime + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" +) + +func (b *MemoryBackend) purgeLiveQueuesLocked(now time.Time) { + for key, state := range b.steerQueues { + if !state.UpdatedAt.IsZero() && now.Sub(state.UpdatedAt) >= b.stateTTL { + delete(b.steerQueues, key) + } + } + for key, state := range b.followUpQueues { + if !state.UpdatedAt.IsZero() && now.Sub(state.UpdatedAt) >= b.stateTTL { + delete(b.followUpQueues, key) + } + } +} + +func (b *MemoryBackend) liveSnapshotLocked(key Key, now time.Time) (Snapshot, bool) { + b.purgeExpiredLocked(now) + snapshot, ok := b.snapshots[key.String()] + return snapshot, ok +} + +func (b *MemoryBackend) EnqueueSteer(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (SteerItem, error) { + if err := contextError(ctx); err != nil { + return SteerItem{}, err + } + if err := validateQueueKey(key); err != nil || strings.TrimSpace(itemID) == "" || strings.TrimSpace(invocationID) == "" || len(payload) == 0 { + return SteerItem{}, ErrQueueInvalidReference + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return SteerItem{}, ErrLiveQueueUnavailable + } + now := time.Now().UTC() + b.purgeLiveQueuesLocked(now) + state := b.steerQueues[key.String()] + if item, ok, err := replaySteer(state, invocationID, payload); ok { + return item, err + } + snapshot, ok := b.liveSnapshotLocked(key, now) + run, active := activeRun(snapshot, ok) + if !active || state.ClosedRunID == run.RunID { + return SteerItem{}, ErrQueueNoActiveRun + } + if !SteerRunAvailable(run) { + return SteerItem{}, ErrQueueSteerUnsupported + } + item, err := state.enqueue(key, itemID, invocationID, run.RunID, payload, now) + if err == nil { + b.steerQueues[key.String()] = state + } + return item, err +} + +func (b *MemoryBackend) EnqueueFollowUp(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (FollowUpItem, error) { + if err := contextError(ctx); err != nil { + return FollowUpItem{}, err + } + if err := validateQueueKey(key); err != nil || strings.TrimSpace(itemID) == "" || strings.TrimSpace(invocationID) == "" || validatePayload(payload) != nil { + return FollowUpItem{}, ErrQueueInvalidReference + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return FollowUpItem{}, ErrLiveQueueUnavailable + } + now := time.Now().UTC() + b.purgeLiveQueuesLocked(now) + state := b.followUpQueues[key.String()] + if item, ok, err := replayFollowUp(state, invocationID, payload); ok { + return item, err + } + snapshot, ok := b.liveSnapshotLocked(key, now) + run, active := activeRun(snapshot, ok) + if !active { + return FollowUpItem{}, ErrQueueNoActiveRun + } + item, err := state.enqueue(key, itemID, invocationID, run.RunID, payload, now) + if err == nil { + b.followUpQueues[key.String()] = state + } + return item, err +} + +func (b *MemoryBackend) PendingQueues(ctx context.Context, key Key, limit int) ([]SteerItem, []FollowUpItem, error) { + if err := contextError(ctx); err != nil { + return nil, nil, err + } + if err := validateQueueKey(key); err != nil { + return nil, nil, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil, nil, ErrLiveQueueUnavailable + } + b.purgeLiveQueuesLocked(time.Now().UTC()) + return pendingSteers(b.steerQueues[key.String()], limit), pendingFollowUps(b.followUpQueues[key.String()], limit), nil +} + +func (b *MemoryBackend) ReorderSteer(ctx context.Context, key Key, item, before SteerPendingRef) ([]SteerItem, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil, ErrLiveQueueUnavailable + } + now := time.Now().UTC() + b.purgeLiveQueuesLocked(now) + if err := validateQueueKey(key); err != nil { + return nil, err + } + state := b.steerQueues[key.String()] + items, err := reorderSteerState(&state, item, before) + if err != nil { + return nil, err + } + state.UpdatedAt = now + b.steerQueues[key.String()] = state + return items, nil +} + +func (b *MemoryBackend) ReorderFollowUp(ctx context.Context, key Key, item, before FollowUpPendingRef) ([]FollowUpItem, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil, ErrLiveQueueUnavailable + } + now := time.Now().UTC() + b.purgeLiveQueuesLocked(now) + if err := validateQueueKey(key); err != nil { + return nil, err + } + state := b.followUpQueues[key.String()] + items, err := reorderFollowUpState(&state, item, before) + if err != nil { + return nil, err + } + state.UpdatedAt = now + b.followUpQueues[key.String()] = state + return items, nil +} + +func (b *MemoryBackend) UpdateSteer(ctx context.Context, key Key, itemID SteerItemID, payload []byte) (SteerItem, error) { + if err := contextError(ctx); err != nil { + return SteerItem{}, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return SteerItem{}, ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil || itemID == "" || validatePayload(payload) != nil { + return SteerItem{}, ErrQueueInvalidReference + } + state := b.steerQueues[key.String()] + item, err := state.edit(itemID, payload, time.Now().UTC()) + if err == nil { + b.steerQueues[key.String()] = state + } + return item, err +} + +func (b *MemoryBackend) UpdateFollowUp(ctx context.Context, key Key, itemID FollowUpItemID, payload []byte) (FollowUpItem, error) { + if err := contextError(ctx); err != nil { + return FollowUpItem{}, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return FollowUpItem{}, ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil || itemID == "" || validatePayload(payload) != nil { + return FollowUpItem{}, ErrQueueInvalidReference + } + state := b.followUpQueues[key.String()] + item, err := state.edit(itemID, payload, time.Now().UTC()) + if err == nil { + b.followUpQueues[key.String()] = state + } + return item, err +} + +func (b *MemoryBackend) CancelSteer(ctx context.Context, key Key, itemID SteerItemID) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil || itemID == "" { + return ErrQueueInvalidReference + } + state := b.steerQueues[key.String()] + err := state.cancel(itemID, time.Now().UTC()) + if err == nil { + b.steerQueues[key.String()] = state + } + return err +} + +func (b *MemoryBackend) CloseSteerRun(ctx context.Context, key Key, runID string) error { + if err := contextError(ctx); err != nil { + return err + } + runID = strings.TrimSpace(runID) + if err := validateQueueKey(key); err != nil || runID == "" { + return ErrQueueInvalidReference + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + state, ok := b.steerQueues[key.String()] + if !ok { + // No steer was ever admitted for this session; there is nothing to + // seal because a later run has its own run ID. + return nil + } + if closeSteerRun(&state, runID, time.Now().UTC()) { + state.compact() + b.steerQueues[key.String()] = state + } + return nil +} + +func (b *MemoryBackend) CancelFollowUp(ctx context.Context, key Key, itemID FollowUpItemID) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil || itemID == "" { + return ErrQueueInvalidReference + } + state := b.followUpQueues[key.String()] + err := state.cancel(itemID, time.Now().UTC()) + if err == nil { + b.followUpQueues[key.String()] = state + } + return err +} + +func (b *MemoryBackend) PromoteFollowUpToSteer(ctx context.Context, key Key, ref FollowUpPendingRef) (PromoteFollowUpResult, error) { + if err := contextError(ctx); err != nil { + return PromoteFollowUpResult{}, err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return PromoteFollowUpResult{}, ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil || ref.ItemID == "" { + return PromoteFollowUpResult{}, ErrQueueInvalidReference + } + now := time.Now().UTC() + snapshot, ok := b.liveSnapshotLocked(key, now) + run, active := activeRun(snapshot, ok) + steers := b.steerQueues[key.String()] + if !active || steers.ClosedRunID == run.RunID { + return PromoteFollowUpResult{}, ErrQueueNoActiveRun + } + if !SteerRunAvailable(run) { + return PromoteFollowUpResult{}, ErrQueueSteerUnsupported + } + follows := b.followUpQueues[key.String()] + result, err := steers.promote(&follows, key, run.RunID, ref, now, uuid.NewString()) + if err == nil { + b.steerQueues[key.String()] = steers + b.followUpQueues[key.String()] = follows + } + return result, err +} + +func (b *MemoryBackend) ClaimNextSteer(ctx context.Context, handle RunHandle, sealIfEmpty bool) (SteerItem, SteerClaimRef, bool, error) { + if err := contextError(ctx); err != nil { + return SteerItem{}, SteerClaimRef{}, false, err + } + handle = handle.normalized() + if !handle.valid() || handle.OwnerID == "" || handle.FencingToken <= 0 { + return SteerItem{}, SteerClaimRef{}, false, ErrQueueInvalidReference + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return SteerItem{}, SteerClaimRef{}, false, ErrLiveQueueUnavailable + } + now := time.Now().UTC() + snapshot, ok := b.liveSnapshotLocked(handle.key(), now) + if !ok || !runMatchesHandle(snapshot.CurrentRunView, handle) || !runViewOwnedBy(snapshot.CurrentRunView, handle.OwnerID) || !isActiveRunStatus(snapshot.CurrentRunView.Status) { + return SteerItem{}, SteerClaimRef{}, false, ErrRunOwnershipLost + } + if !SteerRunAvailable(snapshot.CurrentRunView) { + return SteerItem{}, SteerClaimRef{}, false, ErrQueueSteerUnsupported + } + state := b.steerQueues[handle.key().String()] + item, claim, claimed := state.claimNext(handle, sealIfEmpty, now) + b.steerQueues[handle.key().String()] = state + return item, claim, claimed, nil +} + +func (b *MemoryBackend) ApplySteer(ctx context.Context, key Key, ref SteerClaimRef) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateSteerClaim(key, ref); err != nil { + return err + } + snapshot, ok := b.liveSnapshotLocked(key, time.Now().UTC()) + if !ok || !runMatchesSteerClaim(snapshot.CurrentRunView, ref) { + return ErrRunOwnershipLost + } + state := b.steerQueues[key.String()] + err := state.apply(ref, time.Now().UTC()) + if err == nil { + b.steerQueues[key.String()] = state + } + return err +} + +func (b *MemoryBackend) ReleaseSteer(ctx context.Context, key Key, ref SteerClaimRef) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateSteerClaim(key, ref); err != nil { + return err + } + snapshot, ok := b.liveSnapshotLocked(key, time.Now().UTC()) + if !ok || !runMatchesSteerClaim(snapshot.CurrentRunView, ref) { + return ErrRunOwnershipLost + } + state := b.steerQueues[key.String()] + err := state.release(ref, time.Now().UTC()) + if err == nil { + b.steerQueues[key.String()] = state + } + return err +} + +func (b *MemoryBackend) ClaimNextFollowUp(ctx context.Context, key Key, triggerRunID string) (FollowUpItem, FollowUpClaimRef, bool, error) { + if err := contextError(ctx); err != nil { + return FollowUpItem{}, FollowUpClaimRef{}, false, err + } + triggerRunID = strings.TrimSpace(triggerRunID) + if triggerRunID == "" { + return FollowUpItem{}, FollowUpClaimRef{}, false, ErrQueueInvalidReference + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return FollowUpItem{}, FollowUpClaimRef{}, false, ErrLiveQueueUnavailable + } + if err := validateQueueKey(key); err != nil { + return FollowUpItem{}, FollowUpClaimRef{}, false, err + } + state := b.followUpQueues[key.String()] + item, claim, claimed := state.claimNext(triggerRunID, time.Now().UTC()) + b.followUpQueues[key.String()] = state + return item, claim, claimed, nil +} + +func (b *MemoryBackend) ApplyFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateFollowUpClaim(key, ref); err != nil { + return err + } + state := b.followUpQueues[key.String()] + err := state.apply(ref, time.Now().UTC()) + if err == nil { + b.followUpQueues[key.String()] = state + } + return err +} + +func (b *MemoryBackend) ReleaseFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error { + if err := contextError(ctx); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLiveQueueUnavailable + } + if err := validateFollowUpClaim(key, ref); err != nil { + return err + } + state := b.followUpQueues[key.String()] + err := state.release(ref, time.Now().UTC()) + if err == nil { + b.followUpQueues[key.String()] = state + } + return err +} + +var _ LiveQueueBackend = (*MemoryBackend)(nil) diff --git a/internal/agent/runtime/session/queue_capability_test.go b/internal/agent/runtime/session/queue_capability_test.go new file mode 100644 index 000000000..8192cd4ab --- /dev/null +++ b/internal/agent/runtime/session/queue_capability_test.go @@ -0,0 +1,67 @@ +package sessionruntime + +import ( + "context" + "errors" + "testing" + + "github.com/felinics/memoh/internal/agent/runtime/native" +) + +func TestSteerRequiresConsumerAndRejectsFinishingRun(t *testing.T) { + for _, tc := range []struct { + name, status string + supported bool + }{ + {"old or unsupported owner", RunStatusRunning, false}, + {"terminal proposal", RunStatusFinishing, true}, + {"abort in progress", RunStatusAborting, true}, + } { + t.Run(tc.name, func(t *testing.T) { + backend, key, _ := liveQueueFixture(t) + ctx := context.Background() + follow, err := backend.EnqueueFollowUp(ctx, key, "follow", "follow-invocation", []byte(`{"text":"later"}`)) + if err != nil { + t.Fatal(err) + } + if _, _, err := backend.Update(ctx, key, func(snapshot Snapshot, _ bool) (Snapshot, bool, error) { + snapshot.CurrentRunView.Status = tc.status + snapshot.CurrentRunView.SteerSupported = tc.supported + return snapshot, true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := backend.EnqueueSteer(ctx, key, "steer", "steer-invocation", []byte(`{"text":"now"}`)); !errors.Is(err, ErrQueueSteerUnsupported) { + t.Fatalf("steer admission=%v", err) + } + if _, err := backend.PromoteFollowUpToSteer(ctx, key, FollowUpPendingRef{ItemID: follow.ID}); !errors.Is(err, ErrQueueSteerUnsupported) { + t.Fatalf("promotion=%v", err) + } + steers, follows, err := backend.PendingQueues(ctx, key, 0) + if err != nil || len(steers) != 0 || len(follows) != 1 { + t.Fatalf("rejected promotion changed queues: %v %v %v", steers, follows, err) + } + }) + } +} + +func TestContinuationStepIndexUsesCurrentOwnerCursor(t *testing.T) { + f := newAdmitFixture(t) + ctx := context.Background() + admission, err := f.manager.Admit(ctx, f.input("step-cursor", `{"text":"hello"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := f.manager.HandleAgentEvent(ctx, admission.Handle, native.StreamEvent{Type: native.EventStepEnd, StepNumber: 4}); err != nil { + t.Fatal(err) + } + if index, err := f.manager.ContinuationStepIndex(admission.Handle); err != nil || index != 5 { + t.Fatalf("continuation cursor=%d err=%v", index, err) + } + stale := admission.Handle + stale.Generation = "old-generation" + if _, err := f.manager.ContinuationStepIndex(stale); !errors.Is(err, ErrRunOwnershipLost) { + t.Fatalf("stale cursor lookup=%v", err) + } + f.finish(t, admission) +} diff --git a/internal/agent/runtime/session/queue_reorder_regression_test.go b/internal/agent/runtime/session/queue_reorder_regression_test.go new file mode 100644 index 000000000..fc860eef7 --- /dev/null +++ b/internal/agent/runtime/session/queue_reorder_regression_test.go @@ -0,0 +1,31 @@ +package sessionruntime + +import ( + "context" + "fmt" + "testing" +) + +func TestFollowUpRepeatedReorderPreservesCurrentOrder(t *testing.T) { + b, key, _ := liveQueueFixture(t) + ctx := context.Background() + for _, id := range []string{"a", "b", "c", "d"} { + if _, err := b.EnqueueFollowUp(ctx, key, id, id, []byte(id)); err != nil { + t.Fatal(err) + } + } + if _, err := b.ReorderFollowUp(ctx, key, FollowUpPendingRef{ItemID: "d"}, FollowUpPendingRef{ItemID: "a"}); err != nil { + t.Fatal(err) + } + got, err := b.ReorderFollowUp(ctx, key, FollowUpPendingRef{ItemID: "c"}, FollowUpPendingRef{ItemID: "b"}) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, item := range got { + ids = append(ids, string(item.ID)) + } + if fmt.Sprint(ids) != "[d a c b]" { + t.Fatalf("second reorder = %v, want [d a c b]; first move was lost", ids) + } +} diff --git a/internal/agent/runtime/session/reaper.go b/internal/agent/runtime/session/reaper.go index a1fd2caad..08f0791d0 100644 --- a/internal/agent/runtime/session/reaper.go +++ b/internal/agent/runtime/session/reaper.go @@ -42,6 +42,7 @@ type Reaper struct { recoverWaitingDecision func(context.Context, LeaseCandidate) (bool, error) terminalObserver func(context.Context, TerminalRun) terminalReconciler func(context.Context) error + cancelLostRunDecisions func(context.Context, string, string, string, int64, string) error // generation is the liveness incarnation last observed. Runs stamped with // anything else were claimed by a backend that no longer exists. @@ -61,6 +62,16 @@ type Reaper struct { done chan struct{} } +// SetLostRunDecisionCanceller installs the application-owned cleanup for +// decisions created by a run that is durably marked lost. It is deliberately +// run-scoped: canceling a whole session could expire a newer run's prompt. +func (r *Reaper) SetLostRunDecisionCanceller(canceller func(context.Context, string, string, string, int64, string) error) { + if r == nil || canceller == nil { + return + } + r.cancelLostRunDecisions = canceller +} + // SetWaitingDecisionRecoverer installs the owner-local half of parked-run // recovery. It is set before Start, so the reaper never observes a partially // configured callback. @@ -385,6 +396,17 @@ func (r *Reaper) markLost(ctx context.Context, runID string, fencingToken int64, return nil } } + if r.cancelLostRunDecisions != nil && run.BotID != "" && run.SessionID != "" { + cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + cancelErr := r.cancelLostRunDecisions(cancelCtx, run.BotID, run.SessionID, run.RunID, run.FencingToken, errorCode) + cancel() + if cancelErr != nil { + // The run transition is authoritative and must not be rolled back. + // A later terminal reconciliation/reaper pass can retry decision + // cleanup without changing the lost outcome. + r.logger.Warn("cancel lost run decisions failed", slog.String("run_id", run.RunID), slog.Any("error", cancelErr)) + } + } if r.terminalObserver != nil { r.terminalObserver(context.WithoutCancel(ctx), terminalRunFromLedger(run)) } diff --git a/internal/agent/runtime/session/reaper_test.go b/internal/agent/runtime/session/reaper_test.go index 4797bee4d..b68f5bd56 100644 --- a/internal/agent/runtime/session/reaper_test.go +++ b/internal/agent/runtime/session/reaper_test.go @@ -110,7 +110,7 @@ func newTestReaperWithLiveness(t *testing.T, runs *fakeLedger, live LivenessBack func TestReaperMarksExpiredLeaseLost(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-expired", "session-expired", 5, "generation-1") + runs.InsertClaimed("run-expired", "session-expired", 5, "generation-1") live := newFakeLiveness("generation-1") live.setCandidates(LeaseCandidate{ Key: Key{BotID: testBotID, SessionID: "session-expired"}, @@ -122,10 +122,10 @@ func TestReaperMarksExpiredLeaseLost(t *testing.T) { reaper.tick(context.Background()) - if got := runs.state("run-expired"); got != "lost" { + if got := runs.State("run-expired"); got != "lost" { t.Fatalf("state = %q, want lost", got) } - if got := runs.errorCode("run-expired"); got != runErrorOwnerLeaseExpired { + if got := runs.ErrorCode("run-expired"); got != runErrorOwnerLeaseExpired { t.Fatalf("error code = %q, want %q", got, runErrorOwnerLeaseExpired) } if len(live.releasedCandidates()) != 1 || len(live.indexed()) != 0 { @@ -147,7 +147,7 @@ func TestReaperFinalizesDurableFinishProposalInsteadOfMarkingOwnerLost(t *testin } { t.Run(tt.name, func(t *testing.T) { runs := newFakeLedger() - runs.insertClaimed("run-finishing-"+tt.name, "session-finishing-"+tt.name, 5, "generation-1") + runs.InsertClaimed("run-finishing-"+tt.name, "session-finishing-"+tt.name, 5, "generation-1") prepared, applied, err := runs.PrepareFinish(context.Background(), ledger.PrepareFinishParams{ RunID: "run-finishing-" + tt.name, FencingToken: 5, @@ -170,10 +170,10 @@ func TestReaperFinalizesDurableFinishProposalInsteadOfMarkingOwnerLost(t *testin reaper.tick(context.Background()) - if got := runs.state("run-finishing-" + tt.name); got != tt.proposed { + if got := runs.State("run-finishing-" + tt.name); got != tt.proposed { t.Fatalf("state = %q, want proposed %q rather than lost", got, tt.proposed) } - if got := runs.errorCode("run-finishing-" + tt.name); got != tt.errorCode { + if got := runs.ErrorCode("run-finishing-" + tt.name); got != tt.errorCode { t.Fatalf("error code = %q, want %q", got, tt.errorCode) } if len(observed) != 1 || observed[0].State != string(tt.proposed) { @@ -201,11 +201,11 @@ func TestReaperObservesAppliedAndAlreadyTerminalOutcomes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { runs := newFakeLedger() - runs.insertClaimed("run-observed", "session-observed", 5, "generation-1") + runs.InsertClaimed("run-observed", "session-observed", 5, "generation-1") if tt.abortRequested { - runs.mu.Lock() - runs.runs["run-observed"].AbortRequestedAt = time.Now() - runs.mu.Unlock() + runs.Mu.Lock() + runs.Runs["run-observed"].AbortRequestedAt = time.Now() + runs.Mu.Unlock() } if tt.seedState != "" { if _, applied, err := runs.Finalize(context.Background(), ledger.FinalizeParams{ @@ -241,7 +241,7 @@ func TestReaperObservesAppliedAndAlreadyTerminalOutcomes(t *testing.T) { func TestReaperDoesNotObserveNewerActiveOwner(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-newer-owner", "session-newer-owner", 6, "generation-1") + runs.InsertClaimed("run-newer-owner", "session-newer-owner", 6, "generation-1") live := newFakeLiveness("generation-1") live.setCandidates(LeaseCandidate{ Key: Key{BotID: testBotID, SessionID: "session-newer-owner"}, RunID: "run-newer-owner", FencingToken: 5, @@ -257,7 +257,7 @@ func TestReaperDoesNotObserveNewerActiveOwner(t *testing.T) { if len(observed) != 0 { t.Fatalf("newer active owner emitted terminal observations: %+v", observed) } - if got := runs.state("run-newer-owner"); got != ledger.StateRunning { + if got := runs.State("run-newer-owner"); got != ledger.StateRunning { t.Fatalf("ledger state = %q, want running", got) } } @@ -265,11 +265,11 @@ func TestReaperDoesNotObserveNewerActiveOwner(t *testing.T) { func TestReaperRetriesWaitingDecisionRecoveryAfterTokenHandoff(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-waiting-handoff", "session-waiting-handoff", 5, "generation-1") - runs.mu.Lock() - runs.runs["run-waiting-handoff"].State = ledger.StateWaitingDecision - runs.runs["run-waiting-handoff"].FencingToken = 6 - runs.mu.Unlock() + runs.InsertClaimed("run-waiting-handoff", "session-waiting-handoff", 5, "generation-1") + runs.Mu.Lock() + runs.Runs["run-waiting-handoff"].State = ledger.StateWaitingDecision + runs.Runs["run-waiting-handoff"].FencingToken = 6 + runs.Mu.Unlock() live := newFakeLiveness("generation-1") live.setCandidates(LeaseCandidate{ Key: Key{BotID: testBotID, SessionID: "session-waiting-handoff"}, @@ -287,7 +287,7 @@ func TestReaperRetriesWaitingDecisionRecoveryAfterTokenHandoff(t *testing.T) { if len(recovered) != 1 || recovered[0].FencingToken != 5 { t.Fatalf("recovery calls = %+v, want the stale candidate retried once", recovered) } - if got := runs.state("run-waiting-handoff"); got != ledger.StateWaitingDecision { + if got := runs.State("run-waiting-handoff"); got != ledger.StateWaitingDecision { t.Fatalf("ledger state = %q, want waiting_decision", got) } if len(live.indexed()) != 0 || len(live.releasedCandidates()) != 1 { @@ -330,8 +330,8 @@ func TestReaperRunsTerminalReconcilerOnlyAsLeader(t *testing.T) { func TestReaperKeepsCandidateWhenTerminalWriteFails(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-retry", "session-retry", 5, "generation-1") - runs.setFinalizeErr(errors.New("database is unreachable")) + runs.InsertClaimed("run-retry", "session-retry", 5, "generation-1") + runs.SetFinalizeErr(errors.New("database is unreachable")) live := newFakeLiveness("generation-1") live.setCandidates(LeaseCandidate{ Key: Key{BotID: testBotID, SessionID: "session-retry"}, @@ -344,13 +344,13 @@ func TestReaperKeepsCandidateWhenTerminalWriteFails(t *testing.T) { if len(live.indexed()) != 1 { t.Fatal("failed transition must leave the candidate indexed for the next tick") } - if got := runs.state("run-retry"); got != "running" { + if got := runs.State("run-retry"); got != "running" { t.Fatalf("state = %q, want running", got) } - runs.setFinalizeErr(nil) + runs.SetFinalizeErr(nil) reaper.tick(context.Background()) - if got := runs.state("run-retry"); got != "lost" { + if got := runs.State("run-retry"); got != "lost" { t.Fatalf("state after retry = %q, want lost", got) } if len(live.indexed()) != 0 { @@ -363,7 +363,7 @@ func TestReaperKeepsCandidateWhenTerminalWriteFails(t *testing.T) { func TestReaperStaleTokenCannotCondemnReclaimedRun(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-reclaimed", "session-reclaimed", 9, "generation-1") + runs.InsertClaimed("run-reclaimed", "session-reclaimed", 9, "generation-1") live := newFakeLiveness("generation-1") live.setCandidates(LeaseCandidate{ Key: Key{BotID: testBotID, SessionID: "session-reclaimed"}, @@ -374,7 +374,7 @@ func TestReaperStaleTokenCannotCondemnReclaimedRun(t *testing.T) { reaper.tick(context.Background()) - if got := runs.state("run-reclaimed"); got != "running" { + if got := runs.State("run-reclaimed"); got != "running" { t.Fatalf("state = %q, want running; a stale token must not condemn a reclaimed run", got) } } @@ -384,24 +384,24 @@ func TestReaperStaleTokenCannotCondemnReclaimedRun(t *testing.T) { func TestReaperRecoversRunsFromLostBackendGeneration(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-stale-a", "session-stale-a", 1, "generation-old") - runs.insertClaimed("run-stale-b", "session-stale-b", 2, "generation-old") - runs.insertClaimed("run-stale-c", "session-stale-c", 3, "generation-old") - runs.insertClaimed("run-current", "session-current", 4, "generation-new") + runs.InsertClaimed("run-stale-a", "session-stale-a", 1, "generation-old") + runs.InsertClaimed("run-stale-b", "session-stale-b", 2, "generation-old") + runs.InsertClaimed("run-stale-c", "session-stale-c", 3, "generation-old") + runs.InsertClaimed("run-current", "session-current", 4, "generation-new") live := newFakeLiveness("generation-new") reaper := newTestReaper(t, runs, live) reaper.tick(context.Background()) for _, runID := range []string{"run-stale-a", "run-stale-b", "run-stale-c"} { - if got := runs.state(runID); got != "lost" { + if got := runs.State(runID); got != "lost" { t.Fatalf("%s state = %q, want lost", runID, got) } - if got := runs.errorCode(runID); got != runErrorBackendLost { + if got := runs.ErrorCode(runID); got != runErrorBackendLost { t.Fatalf("%s error code = %q, want %q", runID, got, runErrorBackendLost) } } - if got := runs.state("run-current"); got != "running" { + if got := runs.State("run-current"); got != "running" { t.Fatalf("current generation run = %q, want running", got) } } @@ -411,14 +411,14 @@ func TestReaperRecoversRunsFromLostBackendGeneration(t *testing.T) { func TestReaperDefersRecoveryUntilBackendLossGrace(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-blip", "session-blip", 1, "generation-old") + runs.InsertClaimed("run-blip", "session-blip", 1, "generation-old") live := newFakeLiveness("generation-new") reaper := newTestReaper(t, runs, live) reaper.generationObservedAt = time.Now() reaper.tick(context.Background()) - if got := runs.state("run-blip"); got != "running" { + if got := runs.State("run-blip"); got != "running" { t.Fatalf("state = %q, want running during the grace period", got) } } @@ -428,16 +428,16 @@ func TestReaperDefersRecoveryUntilBackendLossGrace(t *testing.T) { func TestReaperRepairsOrphanedAdmissions(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertOrphan("run-orphan", "session-orphan", "inv-orphan", "fingerprint") + runs.InsertOrphan("run-orphan", "session-orphan", "inv-orphan", "fingerprint") live := newFakeLiveness("generation-1") reaper := newTestReaper(t, runs, live) reaper.tick(context.Background()) - if got := runs.state("run-orphan"); got != "lost" { + if got := runs.State("run-orphan"); got != "lost" { t.Fatalf("state = %q, want lost", got) } - if got := runs.errorCode("run-orphan"); got != runErrorAdmissionOrphaned { + if got := runs.ErrorCode("run-orphan"); got != runErrorAdmissionOrphaned { t.Fatalf("error code = %q, want %q", got, runErrorAdmissionOrphaned) } } @@ -447,8 +447,8 @@ func TestReaperRepairsOrphanedAdmissions(t *testing.T) { func TestReaperFollowerDoesNothing(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-follower", "session-follower", 1, "generation-old") - runs.insertOrphan("run-follower-orphan", "session-follower-orphan", "inv", "fingerprint") + runs.InsertClaimed("run-follower", "session-follower", 1, "generation-old") + runs.InsertOrphan("run-follower-orphan", "session-follower-orphan", "inv", "fingerprint") live := newFakeLiveness("generation-new") live.leader = false live.setCandidates(LeaseCandidate{RunID: "run-follower", FencingToken: 1}) @@ -456,10 +456,10 @@ func TestReaperFollowerDoesNothing(t *testing.T) { reaper.tick(context.Background()) - if got := runs.state("run-follower"); got != "running" { + if got := runs.State("run-follower"); got != "running" { t.Fatalf("state = %q, want running", got) } - if got := runs.state("run-follower-orphan"); got != "accepted" { + if got := runs.State("run-follower-orphan"); got != "accepted" { t.Fatalf("orphan state = %q, want accepted", got) } } @@ -469,7 +469,7 @@ func TestReaperFollowerDoesNothing(t *testing.T) { func TestReaperTransitionsAreIdempotentAcrossLeaders(t *testing.T) { t.Parallel() runs := newFakeLedger() - runs.insertClaimed("run-failover", "session-failover", 3, "generation-old") + runs.InsertClaimed("run-failover", "session-failover", 3, "generation-old") live := newFakeLiveness("generation-new") first := newTestReaper(t, runs, live) second := newTestReaper(t, runs, live) @@ -477,10 +477,10 @@ func TestReaperTransitionsAreIdempotentAcrossLeaders(t *testing.T) { first.tick(context.Background()) second.tick(context.Background()) - if got := runs.state("run-failover"); got != "lost" { + if got := runs.State("run-failover"); got != "lost" { t.Fatalf("state = %q, want lost", got) } - writes := runs.terminalWrites() + writes := runs.TerminalWrites() if len(writes) != 1 { t.Fatalf("terminal writes = %d, want 1; the repeat must not apply", len(writes)) } diff --git a/internal/agent/runtime/session/recovery.go b/internal/agent/runtime/session/recovery.go index 8d753657f..ab1c25186 100644 --- a/internal/agent/runtime/session/recovery.go +++ b/internal/agent/runtime/session/recovery.go @@ -144,7 +144,7 @@ func (m *Manager) reserveRecoveredWaitingDecision(ctx context.Context, run ledge } generation := m.newGeneration() handle := RunHandle{ - BotID: run.BotID, SessionID: run.SessionID, RunID: run.RunID, + BotID: run.BotID, SessionID: run.SessionID, RunID: run.RunID, OwnerID: m.ownerID, TurnID: run.TurnID, Generation: generation, FencingToken: run.FencingToken, }.normalized() lifecycleBase := runtimefence.WithContext(context.WithoutCancel(ctx), runtimefence.Fence{ @@ -153,7 +153,8 @@ func (m *Manager) reserveRecoveredWaitingDecision(ctx context.Context, run ledge lifecycleCtx, lifecycleCancel := context.WithCancel(lifecycleBase) ctrl := &runControl{ botID: handle.BotID, sessionID: handle.SessionID, runID: handle.RunID, - turnID: handle.TurnID, generation: handle.Generation, fencingToken: handle.FencingToken, + ownerID: m.ownerID, + turnID: handle.TurnID, generation: handle.Generation, fencingToken: handle.FencingToken, lifecycleCtx: lifecycleCtx, lifecycleCancel: lifecycleCancel, converter: chatview.NewUIMessageStreamConverter(), leaseChanged: make(chan struct{}, 1), @@ -208,6 +209,7 @@ func (m *Manager) reserveRecoveredWaitingDecision(ctx context.Context, run ledge view.RunID = handle.RunID view.TurnID = run.TurnID view.Generation = handle.Generation + view.FencingToken = handle.FencingToken view.Status = RunStatusWaitingDecision view.OwnerID = m.ownerID view.OwnerLeaseExpiresAt = &expiresAt diff --git a/internal/agent/runtime/session/recovery_test.go b/internal/agent/runtime/session/recovery_test.go index 7b4a27446..a58973276 100644 --- a/internal/agent/runtime/session/recovery_test.go +++ b/internal/agent/runtime/session/recovery_test.go @@ -157,11 +157,11 @@ func (f *waitingDecisionRecoveryFence) ReclaimWaitingDecision( previousToken, newToken int64, decisions []runtimefence.PreservedDecision, ) error { - f.runs.mu.Lock() + f.runs.Mu.Lock() f.decisions.mu.Lock() defer f.decisions.mu.Unlock() - defer f.runs.mu.Unlock() - run := f.runs.runs[runID] + defer f.runs.Mu.Unlock() + run := f.runs.Runs[runID] if run == nil || run.BotID != botID || run.SessionID != sessionID || run.State != ledger.StateWaitingDecision || run.FencingToken != previousToken { return ErrRunOwnershipLost @@ -209,13 +209,13 @@ func TestWaitingDecisionRecoveryRetriesAfterFenceCommitWhenLiveReservationFails( ) key := Key{BotID: testBotID, SessionID: sessionID} runs := newFakeLedger() - runs.insertClaimed(runID, sessionID, 5, "generation-old") + runs.InsertClaimed(runID, sessionID, 5, "generation-old") if _, applied, err := runs.SetWaitingDecision(context.Background(), runID, 5); err != nil || !applied { t.Fatalf("park run: applied=%v err=%v", applied, err) } - runs.mu.Lock() - runs.token = 5 - runs.mu.Unlock() + runs.Mu.Lock() + runs.Token = 5 + runs.Mu.Unlock() decisions := &fakeDecisionStore{target: DecisionTarget{ Type: CommandUserInputResponse, ID: decisionID, BotID: testBotID, SessionID: sessionID, RunID: runID, TurnID: runID + "-turn", @@ -298,13 +298,13 @@ func TestWaitingDecisionRecoveryPreservesParallelDecisions(t *testing.T) { ) key := Key{BotID: testBotID, SessionID: sessionID} runs := newFakeLedger() - runs.insertClaimed(runID, sessionID, 5, "generation-old") + runs.InsertClaimed(runID, sessionID, 5, "generation-old") if _, applied, err := runs.SetWaitingDecision(context.Background(), runID, 5); err != nil || !applied { t.Fatalf("park run: applied=%v err=%v", applied, err) } - runs.mu.Lock() - runs.token = 5 - runs.mu.Unlock() + runs.Mu.Lock() + runs.Token = 5 + runs.Mu.Unlock() decisions := &fakeDecisionStore{ target: DecisionTarget{ Type: CommandToolApprovalResponse, ID: "decision-approval", diff --git a/internal/agent/runtime/session/redis.go b/internal/agent/runtime/session/redis.go index 3f7160bf6..b1b42cdad 100644 --- a/internal/agent/runtime/session/redis.go +++ b/internal/agent/runtime/session/redis.go @@ -210,7 +210,7 @@ func (b *RedisBackend) Load(ctx context.Context, key Key) (Snapshot, bool, error return Snapshot{}, false, err } var snapshot Snapshot - if err := json.Unmarshal(data, &snapshot); err != nil { + if err := unmarshalSnapshot(data, &snapshot); err != nil { return Snapshot{}, false, err } return snapshot, true, nil @@ -240,7 +240,7 @@ func (b *RedisBackend) Update(ctx context.Context, key Key, update SnapshotUpdat changed = false return nil } - data, err := json.Marshal(next) + data, err := marshalSnapshot(next) if err != nil { return err } @@ -304,7 +304,7 @@ func (b *RedisBackend) UpdateActiveRun(ctx context.Context, key Key, runID, gene changed = false return nil } - data, err := json.Marshal(next) + data, err := marshalSnapshot(next) if err != nil { return err } @@ -366,14 +366,22 @@ func (b *RedisBackend) releaseRun(ctx context.Context, key Key, ref RunRef, upda if err != nil { return err } - if !ok || !stateOK || current.CurrentRunView == nil { + if !stateOK || current.CurrentRunView == nil { return ErrRunOwnershipLost } run := current.CurrentRunView + if !ok { + // Expiry removes the routing lease, not the snapshot's receipt. + // No receipt means no proof: never infer a fence from run ID alone. + if requireLiveLease || run.FencingToken <= 0 || run.FencingToken != ref.FencingToken { + return ErrRunOwnershipLost + } + storedRef = RunRef{BotID: key.BotID, SessionID: key.SessionID, RunID: run.RunID, OwnerID: run.OwnerID, Generation: run.Generation, FencingToken: run.FencingToken} + } identityMismatch := !storedRef.identityMatches(ref) || run.RunID != ref.RunID || run.Generation != ref.Generation || run.OwnerID != ref.OwnerID leaseInvalid := !isActiveRunStatus(run.Status) || run.OwnerLeaseExpiresAt == nil || !now.Before(*run.OwnerLeaseExpiresAt) - fenceMismatch := !requireLiveLease && storedRef.FencingToken != ref.FencingToken + fenceMismatch := !requireLiveLease && (storedRef.FencingToken != ref.FencingToken || (run.FencingToken > 0 && run.FencingToken != ref.FencingToken)) if identityMismatch || fenceMismatch || (requireLiveLease && leaseInvalid) { return ErrRunOwnershipLost } @@ -386,7 +394,7 @@ func (b *RedisBackend) releaseRun(ctx context.Context, key Key, ref RunRef, upda changed = false return nil } - data, err := json.Marshal(next) + data, err := marshalSnapshot(next) if err != nil { return err } @@ -464,7 +472,7 @@ func (b *RedisBackend) StartRun(ctx context.Context, key Key, ref RunRef, update changed = false return nil } - stateData, err := json.Marshal(next) + stateData, err := marshalSnapshot(next) if err != nil { return err } @@ -710,7 +718,7 @@ func (b *RedisBackend) RenewLease(ctx context.Context, key Key, runID, ownerID, return ErrRunOwnershipLost } var snapshot Snapshot - if err := json.Unmarshal(stateData, &snapshot); err != nil { + if err := unmarshalSnapshot(stateData, &snapshot); err != nil { return err } if snapshot.CurrentRunView == nil { @@ -724,7 +732,7 @@ func (b *RedisBackend) RenewLease(ctx context.Context, key Key, runID, ownerID, return nil } run.OwnerLeaseExpiresAt = &expiresAt - nextStateData, err := json.Marshal(snapshot) + nextStateData, err := marshalSnapshot(snapshot) if err != nil { return err } @@ -1015,7 +1023,7 @@ func loadRedisSnapshot(ctx context.Context, tx *redis.Tx, key string) (Snapshot, return Snapshot{}, false, err } var snapshot Snapshot - if err := json.Unmarshal(data, &snapshot); err != nil { + if err := unmarshalSnapshot(data, &snapshot); err != nil { return Snapshot{}, false, err } return snapshot, true, nil diff --git a/internal/agent/runtime/session/redis_lease_index_test.go b/internal/agent/runtime/session/redis_lease_index_test.go index ed7978ef1..a33103bd4 100644 --- a/internal/agent/runtime/session/redis_lease_index_test.go +++ b/internal/agent/runtime/session/redis_lease_index_test.go @@ -261,15 +261,15 @@ func runRedisLeaseIndexContract(t *testing.T, redisURL string) { reserveRuntimeRun(ctx, t, backend, ref, time.Now().Add(-time.Minute)) runs := newFakeLedger() - runs.insertClaimed(runID, ref.SessionID, ref.FencingToken, generation) + runs.InsertClaimed(runID, ref.SessionID, ref.FencingToken, generation) reaper := newTestReaperWithLiveness(t, runs, backend, generation) reaper.tick(ctx) - if got := runs.state(runID); got != "lost" { + if got := runs.State(runID); got != "lost" { t.Fatalf("state = %q, want lost", got) } - if got := runs.errorCode(runID); got != runErrorOwnerLeaseExpired { + if got := runs.ErrorCode(runID); got != runErrorOwnerLeaseExpired { t.Fatalf("error code = %q, want %q", got, runErrorOwnerLeaseExpired) } if _, found := leaseCandidateFor(ctx, t, backend, runID); found { @@ -319,7 +319,7 @@ func runRedisLeaseIndexContract(t *testing.T, redisURL string) { } runs := newFakeLedger() - runs.insertClaimed(runID, ref.SessionID, ref.FencingToken, generation) + runs.InsertClaimed(runID, ref.SessionID, ref.FencingToken, generation) if _, applied, err := runs.PrepareFinish(ctx, ledger.PrepareFinishParams{ RunID: runID, FencingToken: ref.FencingToken, State: ledger.StateCompleted, }); err != nil || !applied { @@ -331,7 +331,7 @@ func runRedisLeaseIndexContract(t *testing.T, redisURL string) { reaper.tick(ctx) - if got := runs.state(runID); got != ledger.StateCompleted { + if got := runs.State(runID); got != ledger.StateCompleted { t.Fatalf("durable state = %q, want completed", got) } snapshot, ok, err := finishingBackend.Load(ctx, key) diff --git a/internal/agent/runtime/session/redis_live_queue.go b/internal/agent/runtime/session/redis_live_queue.go new file mode 100644 index 000000000..86dc24e92 --- /dev/null +++ b/internal/agent/runtime/session/redis_live_queue.go @@ -0,0 +1,519 @@ +package sessionruntime + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const redisQueueMaxRetries = 8 + +func (b *RedisBackend) ensureQueueOpen() error { + if b == nil || b.client == nil { + return ErrLiveQueueUnavailable + } + b.subscriptionsMu.Lock() + closed := b.closed + b.subscriptionsMu.Unlock() + if closed { + return ErrLiveQueueUnavailable + } + return nil +} + +func waitRedisQueueRetry(ctx context.Context, attempt int) error { + if attempt >= redisQueueMaxRetries { + return ErrQueueAdmissionOverloaded + } + delay := time.Duration(1<= 0 { + if reflect.DeepEqual(run.UserTurns[index], incoming) { + continue + } + run.UserTurns[index] = incoming + } else { + run.UserTurns = append(run.UserTurns, incoming) + } + published = append(published, incoming) + changed = true + } + appliedItemID := strings.TrimSpace(update.AppliedSteerItemID) + if appliedItemID != "" { + index := steerTurnIndex(run.SteerTurns, appliedItemID) + if index < 0 { + run.SteerTurns = append(run.SteerTurns, SteerTurnView{ + ItemID: appliedItemID, Status: "applied", Text: steerTurnText(appliedTurn), + TurnID: steerTurnID(appliedTurn), AfterMessageID: maxRuntimeMessageID(run.Messages), Timestamp: now, + }) + index = len(run.SteerTurns) - 1 + } else { + run.SteerTurns[index].Status = "applied" + if appliedTurn != nil { + run.SteerTurns[index].TurnID = strings.TrimSpace(appliedTurn.TurnID) + run.SteerTurns[index].Timestamp = appliedTurn.Timestamp + } + } + steerUpserts = append(steerUpserts, run.SteerTurns[index]) + changed = true + } + claimedItemID := strings.TrimSpace(update.ClaimedSteerItemID) + if claimedItemID != "" { + claimedAt := update.ClaimedSteerTimestamp + if claimedAt.IsZero() { + claimedAt = now + } + incoming := SteerTurnView{ + ItemID: claimedItemID, Status: "claimed", Text: strings.TrimSpace(update.ClaimedSteerText), + AfterMessageID: maxRuntimeMessageID(run.Messages), Timestamp: claimedAt, + } + index := steerTurnIndex(run.SteerTurns, claimedItemID) + if index < 0 { + run.SteerTurns = append(run.SteerTurns, incoming) + } else { + incoming.AfterMessageID = run.SteerTurns[index].AfterMessageID + run.SteerTurns[index] = incoming + } + steerUpserts = append(steerUpserts, incoming) + changed = true + } + if !changed { + return snapshot, false, nil + } + snapshot.Seq++ + snapshot.UpdatedAt = now + run.UpdatedAt = now + return snapshot, true, nil + }, func(Snapshot) RuntimeDelta { + return RuntimeDelta{ + UserTurnUpserts: append([]chatview.UITurn(nil), published...), + SteerTurnUpserts: append([]SteerTurnView(nil), steerUpserts...), + } + }) + return err +} + +func steerTurnIndex(turns []SteerTurnView, itemID string) int { + for i := range turns { + if strings.TrimSpace(turns[i].ItemID) == itemID { + return i + } + } + return -1 +} + +func maxRuntimeMessageID(messages []chatview.UIMessage) int { + maximum := -1 + for i := range messages { + if messages[i].ID > maximum { + maximum = messages[i].ID + } + } + return maximum +} + +func steerTurnID(turn *chatview.UITurn) string { + if turn == nil { + return "" + } + return strings.TrimSpace(turn.TurnID) +} + +func steerTurnText(turn *chatview.UITurn) string { + if turn == nil { + return "" + } + return strings.TrimSpace(turn.Text) +} diff --git a/internal/agent/runtime/session/user_turns_test.go b/internal/agent/runtime/session/user_turns_test.go new file mode 100644 index 000000000..fb1baa179 --- /dev/null +++ b/internal/agent/runtime/session/user_turns_test.go @@ -0,0 +1,264 @@ +package sessionruntime + +import ( + "context" + "testing" + "time" + + "github.com/felinics/memoh/internal/agent/runtime/native" + "github.com/felinics/memoh/internal/agent/turn" + chatview "github.com/felinics/memoh/internal/agent/view" +) + +func TestPublishQueueUserTurnsKeepsAppliedInputsOrderedAndIdempotent(t *testing.T) { + manager := testRuntimeManager(t, NewMemoryBackend(), "owner-user-turns") + const rootTurnID = "turn-root" + handle, err := manager.StartRunWithAdmissionBuilderHandle( + context.Background(), testBotID, testSessionID, testRunID, + func(_ context.Context, _ RunHandle) (RunAdmissionView, error) { + return RunAdmissionView{RequestUserTurn: &chatview.UITurn{ + TurnID: rootTurnID, Role: "user", Text: "original", Timestamp: time.Now(), + }}, nil + }, + make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1), + ) + if err != nil { + t.Fatalf("start run: %v", err) + } + + root := chatview.UITurn{ + TurnID: rootTurnID, Role: "user", Text: "original", ID: "persisted-root", Timestamp: time.Now(), + } + steerOne := chatview.UITurn{ + TurnID: "turn-steer-1", Role: "user", Text: "first steer", ID: "persisted-steer-1", Timestamp: time.Now(), + } + steerTwo := chatview.UITurn{ + TurnID: "turn-steer-2", Role: "user", Text: "second steer", ID: "persisted-steer-2", Timestamp: time.Now(), + } + if err := manager.PublishQueueUserTurns(context.Background(), handle, QueueUserTurnUpdate{PersistedTurns: []chatview.UITurn{root, steerOne}}); err != nil { + t.Fatalf("publish first persisted turns: %v", err) + } + if err := manager.PublishQueueUserTurns(context.Background(), handle, QueueUserTurnUpdate{PersistedTurns: []chatview.UITurn{steerOne, steerTwo}}); err != nil { + t.Fatalf("publish overlapping persisted turns: %v", err) + } + + snapshot, err := manager.Snapshot(context.Background(), testBotID, testSessionID) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.CurrentRunView == nil { + t.Fatal("current run view is missing") + } + got := snapshot.CurrentRunView.UserTurns + if len(got) != 3 { + t.Fatalf("user turns = %#v, want original plus two steers", got) + } + for i, want := range []string{"original", "first steer", "second steer"} { + if got[i].Text != want { + t.Fatalf("user turn %d = %q, want %q", i, got[i].Text, want) + } + } + if got[0].ID != "persisted-root" { + t.Fatalf("root user turn was not upgraded to persisted identity: %#v", got[0]) + } +} + +func TestPublishQueueUserTurnsLocatesClaimAndAppliesItWithoutDuplicateProjection(t *testing.T) { + manager := testRuntimeManager(t, NewMemoryBackend(), "owner-queue-turns") + handle, err := manager.StartRunWithAdmissionBuilderHandle( + context.Background(), testBotID, testSessionID, testRunID, + func(_ context.Context, _ RunHandle) (RunAdmissionView, error) { + return RunAdmissionView{RequestUserTurn: &chatview.UITurn{ + TurnID: "turn-root", Role: "user", Text: "original", Timestamp: time.Now(), + }}, nil + }, + make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1), + ) + if err != nil { + t.Fatalf("start run: %v", err) + } + if _, err := manager.HandleAgentEvent(context.Background(), handle, native.StreamEvent{Type: native.EventTextStart}); err != nil { + t.Fatalf("start assistant output: %v", err) + } + if _, err := manager.HandleAgentEvent(context.Background(), handle, native.StreamEvent{ + Type: native.EventTextDelta, Delta: "before steer", + }); err != nil { + t.Fatalf("publish assistant output: %v", err) + } + subscription, err := manager.Subscribe(context.Background(), testBotID, testSessionID) + if err != nil { + t.Fatalf("subscribe runtime: %v", err) + } + defer subscription.Close() + select { + case event := <-subscription.C: + if event.Type != EventRuntimeSnapshot { + t.Fatalf("initial runtime event = %q, want snapshot", event.Type) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for initial runtime snapshot") + } + claimedAt := time.Now().UTC() + if err := manager.PublishQueueUserTurns(context.Background(), handle, QueueUserTurnUpdate{ + ClaimedSteerItemID: "steer-item-1", ClaimedSteerText: "change direction", ClaimedSteerTimestamp: claimedAt, + }); err != nil { + t.Fatalf("publish claimed steer: %v", err) + } + select { + case event := <-subscription.C: + if event.Type != EventRuntimeDelta || event.Delta == nil || len(event.Delta.SteerTurnUpserts) != 1 { + t.Fatalf("claimed steer event = %#v, want one runtime delta upsert", event) + } + if got := event.Delta.SteerTurnUpserts[0]; got.ItemID != "steer-item-1" || got.Status != "claimed" { + t.Fatalf("claimed steer delta = %#v", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for claimed steer runtime delta") + } + + claimed, err := manager.Snapshot(context.Background(), testBotID, testSessionID) + if err != nil { + t.Fatalf("claimed snapshot: %v", err) + } + if got := claimed.CurrentRunView.SteerTurns; len(got) != 1 || got[0].Status != "claimed" || got[0].AfterMessageID < 0 { + t.Fatalf("claimed steer turns = %#v", got) + } + + durable := chatview.UITurn{ + TurnID: "turn-steer-1", Role: "user", Text: "change direction", ID: "message-steer-1", Timestamp: time.Now().UTC(), + } + if err := manager.PublishQueueUserTurns(context.Background(), handle, QueueUserTurnUpdate{ + PersistedTurns: []chatview.UITurn{durable}, AppliedSteerItemID: "steer-item-1", AppliedSteerTurn: &durable, + }); err != nil { + t.Fatalf("publish applied steer: %v", err) + } + applied, err := manager.Snapshot(context.Background(), testBotID, testSessionID) + if err != nil { + t.Fatalf("applied snapshot: %v", err) + } + if got := applied.CurrentRunView.SteerTurns; len(got) != 1 || got[0].Status != "applied" || got[0].TurnID != durable.TurnID { + t.Fatalf("applied steer turns = %#v", got) + } + if got := applied.CurrentRunView.UserTurns; len(got) != 2 || got[1].TurnID != durable.TurnID { + t.Fatalf("durable user turns = %#v", got) + } +} + +// TestPublishQueueUserTurnsAnchorsClaimedSteerAfterCommittedStepOutput pins +// the fix for a steer rendering above assistant output that preceded it. The +// commit barrier runs on the model loop while agent events are consumed on +// another goroutine; a claimed steer that names the committed step must wait +// until that step's step_end marker has been consumed before it reads the +// projection to compute its anchor. +func TestPublishQueueUserTurnsAnchorsClaimedSteerAfterCommittedStepOutput(t *testing.T) { + manager := testRuntimeManager(t, NewMemoryBackend(), "owner-steer-anchor") + handle, err := manager.StartRunWithAdmissionBuilderHandle( + context.Background(), testBotID, testSessionID, testRunID, + func(_ context.Context, _ RunHandle) (RunAdmissionView, error) { + return RunAdmissionView{RequestUserTurn: &chatview.UITurn{ + TurnID: "turn-root", Role: "user", Text: "original", Timestamp: time.Now(), + }}, nil + }, + make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1), + ) + if err != nil { + t.Fatalf("start run: %v", err) + } + ctx := context.Background() + // Step 0 output that the consumer has already applied. + for _, ev := range []native.StreamEvent{ + {Type: native.EventTextStart}, + {Type: native.EventTextDelta, Delta: "early"}, + {Type: native.EventTextEnd}, + } { + if _, err := manager.HandleAgentEvent(ctx, handle, ev); err != nil { + t.Fatalf("publish %s: %v", ev.Type, err) + } + } + + // The commit for step 0 publishes the claimed steer now, but the tail of + // step 0 (a tool block plus the step_end marker) is still in flight. + stepZero := 0 + published := make(chan error, 1) + go func() { + published <- manager.PublishQueueUserTurns(ctx, handle, QueueUserTurnUpdate{ + ClaimedSteerItemID: "steer-1", ClaimedSteerText: "change course", + ClaimedSteerTimestamp: time.Now().UTC(), AfterStepIndex: &stepZero, + }) + }() + select { + case err := <-published: + t.Fatalf("steer published before step 0 was consumed: %v", err) + case <-time.After(50 * time.Millisecond): + } + + // Late tail of step 0 arrives and is consumed. + for _, ev := range []native.StreamEvent{ + {Type: native.EventToolCallStart, ToolCallID: "call-1", ToolName: "exec"}, + {Type: native.EventToolCallEnd, ToolCallID: "call-1", ToolName: "exec", Result: "ok"}, + {Type: native.EventStepEnd, StepNumber: 0}, + } { + if _, err := manager.HandleAgentEvent(ctx, handle, ev); err != nil { + t.Fatalf("publish late %s: %v", ev.Type, err) + } + } + select { + case err := <-published: + if err != nil { + t.Fatalf("publish claimed steer: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("steer publish did not resume after step_end was consumed") + } + + snapshot, err := manager.Snapshot(ctx, testBotID, testSessionID) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + run := snapshot.CurrentRunView + if run == nil || len(run.SteerTurns) != 1 { + t.Fatalf("steer turns = %#v", run) + } + maxID := -1 + for _, message := range run.Messages { + if message.ID > maxID { + maxID = message.ID + } + } + if got := run.SteerTurns[0].AfterMessageID; got != maxID { + t.Fatalf("steer anchored after message %d, want %d (the last message of the committed step)", got, maxID) + } + if len(run.Messages) < 2 { + t.Fatalf("expected text and tool blocks before the steer, got %#v", run.Messages) + } +} + +func TestPublishQueueUserTurnsWithoutStepIndexDoesNotWait(t *testing.T) { + manager := testRuntimeManager(t, NewMemoryBackend(), "owner-steer-nowait") + handle, err := manager.StartRunWithAdmissionBuilderHandle( + context.Background(), testBotID, testSessionID, testRunID, + func(_ context.Context, _ RunHandle) (RunAdmissionView, error) { + return RunAdmissionView{RequestUserTurn: &chatview.UITurn{ + TurnID: "turn-root", Role: "user", Text: "original", Timestamp: time.Now(), + }}, nil + }, + make(chan struct{}, 1), func() {}, make(chan turn.InjectMessage, 1), + ) + if err != nil { + t.Fatalf("start run: %v", err) + } + done := make(chan error, 1) + go func() { + done <- manager.PublishQueueUserTurns(context.Background(), handle, QueueUserTurnUpdate{ + ClaimedSteerItemID: "steer-1", ClaimedSteerText: "recovered", ClaimedSteerTimestamp: time.Now().UTC(), + }) + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("publish without step index: %v", err) + } + case <-time.After(time.Second): + t.Fatal("publish without AfterStepIndex must not block") + } +} diff --git a/internal/agent/turn/grpctransport/client.go b/internal/agent/turn/grpctransport/client.go index 9caec48a4..8c5125a14 100644 --- a/internal/agent/turn/grpctransport/client.go +++ b/internal/agent/turn/grpctransport/client.go @@ -246,6 +246,11 @@ func mapClientError(err error) error { return turn.ErrSessionBusy case codes.AlreadyExists: return turn.ErrDuplicateTurn + case codes.ResourceExhausted: + if status.Convert(err).Message() == turnDeferredStatusMessage { + return turn.ErrTurnDeferred + } + return err case codes.PermissionDenied: return turn.ErrTeamNotServed case codes.Canceled: diff --git a/internal/agent/turn/grpctransport/server.go b/internal/agent/turn/grpctransport/server.go index fbc2d1b36..0ecfb7ab5 100644 --- a/internal/agent/turn/grpctransport/server.go +++ b/internal/agent/turn/grpctransport/server.go @@ -239,6 +239,11 @@ func (s *Server) mapError(operation string, err error) error { return status.Error(codes.Aborted, "thread busy") case errors.Is(err, turn.ErrDuplicateTurn): return status.Error(codes.AlreadyExists, "duplicate turn") + case errors.Is(err, turn.ErrTurnDeferred): + // A deferred turn is an accepted admission result, not a failure. + // Preserve it across the process boundary so channel adapters can + // acknowledge the queued message. + return status.Error(codes.ResourceExhausted, turnDeferredStatusMessage) case errors.Is(err, turn.ErrTeamNotServed): return status.Error(codes.PermissionDenied, "team is not served") case errors.Is(err, context.Canceled): diff --git a/internal/agent/turn/grpctransport/transport_test.go b/internal/agent/turn/grpctransport/transport_test.go index 1882daf67..e06e26ad3 100644 --- a/internal/agent/turn/grpctransport/transport_test.go +++ b/internal/agent/turn/grpctransport/transport_test.go @@ -22,6 +22,11 @@ import ( intrpc "github.com/felinics/memoh/internal/rpc" ) +const ( + legacySessionIDKey = "SessionID" + internalThreadIDKey = "ThreadID" +) + func TestStartTurnRoundTrip(t *testing.T) { fake := &fakeService{} client, cleanup := newTestClient(t, fake, "secret") @@ -125,6 +130,24 @@ func TestLegacySessionIDJSONWireCompatibility(t *testing.T) { if threadID != "thread-1" { t.Fatalf("roundtrip ThreadID = %q, want thread-1", threadID) } + for _, tc := range []struct { + name, data, want string + invalid bool + }{ + {"legacy", `{"SessionID":"legacy"}`, "legacy", false}, + {"internal", `{"ThreadID":"internal"}`, "internal", false}, + {"both", `{"SessionID":"same","ThreadID":"same"}`, "", true}, + {"null-conflict", `{"SessionID":null,"ThreadID":"internal"}`, "", true}, + {"invalid-id", `{"SessionID":123}`, "", true}, + {"absent", `{}`, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tt.unmarshal([]byte(tc.data)) + if (err != nil) != tc.invalid || (!tc.invalid && got != tc.want) { + t.Fatalf("decoded %s: id=%q err=%v", tc.data, got, err) + } + }) + } }) } } diff --git a/internal/agent/turn/grpctransport/wire.go b/internal/agent/turn/grpctransport/wire.go index 618fa2c0a..ba12c725f 100644 --- a/internal/agent/turn/grpctransport/wire.go +++ b/internal/agent/turn/grpctransport/wire.go @@ -9,73 +9,98 @@ import ( ) const ( - legacySessionIDKey = "SessionID" - internalThreadIDKey = "ThreadID" + // turnDeferredStatusMessage is part of the private gRPC vocabulary. Keep it + // centralized because the client uses it to distinguish a deferred + // admission result from other ResourceExhausted failures. + turnDeferredStatusMessage = "turn deferred" ) // The authenticated server-channel RPC predates the internal Thread // terminology. Keep its JSON field named SessionID so independently deployed // server and channel binaries remain compatible while the domain contract uses // ThreadID exclusively. +// A tagged field at the same embedding depth shadows the domain ThreadID. +// The output still contains SessionID only; the domain JSON used by queue +// payloads is unchanged. Embedding avoids copying the command's field list. +type outgoingThreadID struct { + SessionID string + ThreadID *string `json:"ThreadID,omitempty"` +} + +type incomingThreadID struct { + SessionID json.RawMessage `json:"SessionID"` + ThreadID json.RawMessage `json:"ThreadID"` +} + func marshalStartTurnCommand(cmd turn.StartTurnCommand) ([]byte, error) { - return marshalLegacyThreadID(cmd) + return json.Marshal(struct { + turn.StartTurnCommand + outgoingThreadID + }{cmd, outgoingThreadID{SessionID: cmd.ThreadID}}) } func unmarshalStartTurnCommand(data []byte, cmd *turn.StartTurnCommand) error { - return unmarshalLegacyThreadID(data, cmd) + if cmd == nil { + return json.Unmarshal(data, cmd) + } + wire := struct { + *turn.StartTurnCommand + incomingThreadID + }{StartTurnCommand: cmd} + return unmarshalLegacyThreadID(data, &wire, &wire.incomingThreadID, &cmd.ThreadID) } func marshalToolApprovalResponse(input turn.ToolApprovalResponse) ([]byte, error) { - return marshalLegacyThreadID(input) + return json.Marshal(struct { + turn.ToolApprovalResponse + outgoingThreadID + }{input, outgoingThreadID{SessionID: input.ThreadID}}) } func unmarshalToolApprovalResponse(data []byte, input *turn.ToolApprovalResponse) error { - return unmarshalLegacyThreadID(data, input) + if input == nil { + return json.Unmarshal(data, input) + } + wire := struct { + *turn.ToolApprovalResponse + incomingThreadID + }{ToolApprovalResponse: input} + return unmarshalLegacyThreadID(data, &wire, &wire.incomingThreadID, &input.ThreadID) } func marshalUserInputResponse(input turn.UserInputResponse) ([]byte, error) { - return marshalLegacyThreadID(input) + return json.Marshal(struct { + turn.UserInputResponse + outgoingThreadID + }{input, outgoingThreadID{SessionID: input.ThreadID}}) } func unmarshalUserInputResponse(data []byte, input *turn.UserInputResponse) error { - return unmarshalLegacyThreadID(data, input) -} - -func marshalLegacyThreadID(value any) ([]byte, error) { - data, err := json.Marshal(value) - if err != nil { - return nil, err - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(data, &fields); err != nil { - return nil, err + if input == nil { + return json.Unmarshal(data, input) } - threadID, ok := fields[internalThreadIDKey] - if !ok { - return nil, errors.New("turn rpc: internal ThreadID field missing") - } - delete(fields, internalThreadIDKey) - fields[legacySessionIDKey] = threadID - return json.Marshal(fields) + wire := struct { + *turn.UserInputResponse + incomingThreadID + }{UserInputResponse: input} + return unmarshalLegacyThreadID(data, &wire, &wire.incomingThreadID, &input.ThreadID) } -func unmarshalLegacyThreadID(data []byte, value any) error { - var fields map[string]json.RawMessage - if err := json.Unmarshal(data, &fields); err != nil { +func unmarshalLegacyThreadID(data []byte, wire any, ids *incomingThreadID, threadID *string) error { + if err := json.Unmarshal(data, wire); err != nil { return err } - if _, hasLegacy := fields[legacySessionIDKey]; hasLegacy { - if _, hasInternal := fields[internalThreadIDKey]; hasInternal { - return errors.New("turn rpc: ambiguous SessionID and ThreadID fields") - } - fields[internalThreadIDKey] = fields[legacySessionIDKey] - delete(fields, legacySessionIDKey) + if ids.SessionID != nil && ids.ThreadID != nil { + return errors.New("turn rpc: ambiguous SessionID and ThreadID fields") } - adapted, err := json.Marshal(fields) - if err != nil { - return err + value := ids.ThreadID + if ids.SessionID != nil { + value = ids.SessionID + } + if value == nil { + return nil } - return json.Unmarshal(adapted, value) + return json.Unmarshal(value, threadID) } func eventFromProto(event *turnpb.EventResponse) turn.Event { diff --git a/internal/agent/turn/turn.go b/internal/agent/turn/turn.go index aa7b73731..59da8b651 100644 --- a/internal/agent/turn/turn.go +++ b/internal/agent/turn/turn.go @@ -17,20 +17,20 @@ import ( // delivery and drop the duplicate silently. var ErrDuplicateTurn = errors.New("turn: duplicate idempotency key") -// ErrSessionBusy reports that the thread already has a run in flight, so this -// command was not started and nothing was persisted for it. -// -// It is retryable by construction, and that is the whole point: a thread runs -// one turn at a time, and the runtime holds nothing on a caller's behalf. The -// caller redelivers through the retry mechanism it already owns — a platform -// webhook retry, the next cron fire — and because a redelivery repeats the same -// IdempotencyKey, the retry is the same invocation rather than a second turn. +// ErrSessionBusy reports that the thread already has a run in flight. An +// ingress whose user observes runs through the session runtime subscription +// may park the full command with DeferredTurnService and report +// ErrTurnDeferred instead; other callers retry the unchanged command. // // It is declared here rather than reused from the runtime because this package // is the only agent surface Channel may import, and it must not depend on the // runtime that produces the condition. var ErrSessionBusy = errors.New("turn: thread already has a run in flight") +// ErrTurnDeferred means the complete turn command was accepted by the +// configured session runtime queue and will start after the current run ends. +var ErrTurnDeferred = errors.New("turn: deferred until current run completes") + // ErrTeamNotServed reports that the service instance does not serve the // command's team. The in-process runtime binds its database pool to the // single self-hosted team, so commands for any other team must fail @@ -51,8 +51,12 @@ const ( // outbound assets through RunHandle.AddOutboundAssets. type StartTurnCommand struct { SchemaVersion int - TeamID string // required; the service fails closed when empty - Mode Mode + // NoDefer is reserved for server-owned continuation attempts. A follow-up + // that is already being started must surface ErrSessionBusy instead of + // re-entering the follow-up queue when it races another admission. + NoDefer bool + TeamID string // required; the service fails closed when empty + Mode Mode BotID string ChatID string @@ -238,3 +242,12 @@ type StopCommand struct{ TeamID, BotID, ThreadID string } type Stopper interface { StopTurn(context.Context, StopCommand) (bool, error) } + +// DeferredTurnService parks a complete user turn that arrived while its +// session was busy. The run it later starts has no handle consumer, so only an +// ingress that delivers output through the session runtime subscription (web, +// cli) may use it; platform channels stream replies from the handle and must +// not. +type DeferredTurnService interface { + EnqueueDeferredTurn(context.Context, StartTurnCommand) error +} diff --git a/internal/apperror/error.go b/internal/apperror/error.go index ce4bd83e6..66ed55a57 100644 --- a/internal/apperror/error.go +++ b/internal/apperror/error.go @@ -86,6 +86,13 @@ const ( CodeSessionHistoryInconsistent Code = "session_runtime.history_inconsistent" CodeAgentResponseTimeout Code = "agent.response_timeout" CodeAgentResponseInterrupted Code = "agent.response_interrupted" + CodeQueueSteerUnsupported Code = "queue.steer_unsupported" + CodeQueueNoActiveRun Code = "queue_no_active_run" + CodeQueueAdmissionOverloaded Code = "queue_admission_overloaded" + CodeQueueAdmissionUnavailable Code = "queue_admission_unavailable" + CodeQueueRequestInvalid Code = "queue_request_invalid" + CodeQueueItemNotPending Code = "queue_item_not_pending" + CodeQueueCapacityExceeded Code = "queue_capacity_exceeded" CodeContextLifecycleRequestInvalid Code = "context_lifecycle.request_invalid" CodeContextLifecycleAuthenticationRequired Code = "context_lifecycle.authentication_required" @@ -459,6 +466,34 @@ var catalog = map[Code]Definition{ HTTPStatus: http.StatusBadGateway, Detail: "The model response was interrupted. Please try again.", }, + CodeQueueSteerUnsupported: { + HTTPStatus: http.StatusConflict, + Detail: "This run cannot accept steer input. Wait for it to finish and send a new message.", + }, + CodeQueueNoActiveRun: { + HTTPStatus: http.StatusConflict, + Detail: "There is no active run to receive this queued input.", + }, + CodeQueueAdmissionOverloaded: { + HTTPStatus: http.StatusTooManyRequests, + Detail: "The queue is busy. Please retry this request shortly.", + }, + CodeQueueAdmissionUnavailable: { + HTTPStatus: http.StatusServiceUnavailable, + Detail: "The queue admission service is temporarily unavailable. Please retry shortly.", + }, + CodeQueueRequestInvalid: { + HTTPStatus: http.StatusBadRequest, + Detail: "The queue request is invalid.", + }, + CodeQueueItemNotPending: { + HTTPStatus: http.StatusConflict, + Detail: "This queue item is no longer accepted and pending.", + }, + CodeQueueCapacityExceeded: { + HTTPStatus: http.StatusConflict, + Detail: "This session queue is full. Cancel or wait for pending items before adding more.", + }, CodeContextLifecycleRequestInvalid: { HTTPStatus: http.StatusBadRequest, Detail: "The context lifecycle request is invalid.", diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 61dbe711f..fe1ca93ab 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -46,6 +46,9 @@ const ( silentReplyToken = "NO_REPLY" minDuplicateTextLength = 10 processingStatusTimeout = 60 * time.Second + turnBusyRetryWindow = 30 * time.Second + turnBusyRetryInitial = 100 * time.Millisecond + turnBusyRetryMax = time.Second ) var whitespacePattern = regexp.MustCompile(`\s+`) @@ -189,13 +192,13 @@ type ChannelInboundProcessor struct { mediaService mediaIngestor reactor channelReactor commandHandler CommandHandler + queueCommandHandler QueueCommandHandler registry *channel.Registry logger *slog.Logger jwtSecret string tokenTTL time.Duration identity *IdentityResolver policy PolicyService - dispatcher *RouteDispatcher acl chatACL observer channel.StreamObserver speechService speechSynthesizer @@ -328,6 +331,14 @@ func (p *ChannelInboundProcessor) SetCommandHandler(handler CommandHandler) { p.commandHandler = handler } +// SetQueueCommandHandler configures live queue slash controls. +func (p *ChannelInboundProcessor) SetQueueCommandHandler(handler QueueCommandHandler) { + if p == nil { + return + } + p.queueCommandHandler = handler +} + func (p *ChannelInboundProcessor) SetRequestedSkillResolver(resolver RequestedSkillResolver) { if p == nil { return @@ -345,14 +356,6 @@ func (p *ChannelInboundProcessor) SetPipeline(pipeline *timeline.Pipeline, store p.discussDriver = driver } -// SetDispatcher configures the per-route message dispatcher for inject/queue/parallel modes. -func (p *ChannelInboundProcessor) SetDispatcher(dispatcher *RouteDispatcher) { - if p == nil { - return - } - p.dispatcher = dispatcher -} - // SetIMDisplayOptions configures the reader used to gate IM-facing stream // events (e.g. tool call lifecycle) on bot-level display preferences. When // nil, tool call events are always dropped before reaching IM adapters. @@ -487,7 +490,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel isStatusCommand := invocationHasResource(invocation, "status", "context") isToolApprovalCommand := invocationHasResource(invocation, "approve", "reject") isUserInputResponseCommand := invocationHasResource(invocation, "respond") - isModeCommand := invocationHasResource(invocation, "now", "next", "btw") + isQueueCommand := invocationHasResource(invocation, "queue", "steer") var pendingSkillIntent *slash.SkillIntent switch slashDecision.Kind { case slash.DecisionRejectNoop: @@ -550,9 +553,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel return p.handleStatusCommand(ctx, cfg, msg, sender, identity, *invocation) } - // Skip generic command handler for mode-prefix commands (/btw, /now, /next) - // so they pass through to mode detection below. - if pendingSkillIntent == nil && slashDecision.Kind == slash.DecisionCommandAction && p.commandHandler != nil && !isModeCommand && !isToolApprovalCommand && !isUserInputResponseCommand && invocation != nil && (isDirectedAtBot(msg) || slashDirected) { + if pendingSkillIntent == nil && slashDecision.Kind == slash.DecisionCommandAction && p.commandHandler != nil && !isToolApprovalCommand && !isUserInputResponseCommand && !isQueueCommand && invocation != nil && (isDirectedAtBot(msg) || slashDirected) { loc := p.localizer(ctx, identity.BotID) result, err := p.commandHandler.ExecuteResult(ctx, command.ExecuteInput{ BotID: strings.TrimSpace(identity.BotID), @@ -605,16 +606,6 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel replyAttachments := mapChannelToChatAttachments(replyAttachmentsFromMessage(msg.Message.Reply)) text = strings.TrimSpace(msg.Message.PlainText()) - // Detect inbound mode from message prefix (/btw, /now, /next). - // Only applies to non-local channels; WebUI always uses the default flow. - // Must run after buildInboundQuery so the prefix is stripped from the final text. - inboundMode := ModeInject - if !isLocalChannelType(msg.Channel) { - if isModeCommand && invocation != nil { - text = invocation.CommandText - } - inboundMode, text = DetectMode(text) - } threadID := extractThreadID(msg) // Resolve or create the route via channel_routes. @@ -724,18 +715,16 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel if isUserInputResponseCommand && invocation != nil && (isDirectedAtBot(msg) || slashDirected) { return p.handleUserInputResponseCommand(ctx, msg, sender, identity, resolved.RouteID, sessionID, *invocation) } - // Mode and skill commands remain control-plane messages even while an - // ask_user request is pending; they must not become text-question answers. - if pendingSkillIntent == nil && !isModeCommand { + if isQueueCommand && invocation != nil && (isDirectedAtBot(msg) || slashDirected) { + return p.handleQueueCommand(ctx, msg, sender, identity, resolved.RouteID, sessionID, sessionType, *invocation) + } + // Skill commands remain control-plane messages even while an ask_user + // request is pending; they must not become text-question answers. + if pendingSkillIntent == nil { if handled, err := p.handlePlainTextUserInput(ctx, cfg, msg, sender, identity, resolved.RouteID, sessionID, text); handled || err != nil { return err } } - if pendingSkillIntent != nil && p.dispatcher != nil && !isLocalChannelType(msg.Channel) && inboundMode != ModeParallel { - if p.dispatcher.IsActive(strings.TrimSpace(resolved.RouteID)) { - return p.sendSlashError(ctx, sender, msg, slash.CodeUnsupportedSkillSlashContext) - } - } var requestedSkillContexts []turn.RequestedSkillContext var skillActivation *turn.SkillActivation @@ -944,68 +933,6 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel return nil } - routeID := strings.TrimSpace(resolved.RouteID) - - // --- Dispatcher-based mode handling (inject / queue) --- - // For non-parallel modes, when a route already has an active agent stream, - // short-circuit here instead of starting a new stream. - if p.dispatcher != nil && !isLocalChannelType(msg.Channel) && inboundMode != ModeParallel { - if p.dispatcher.IsActive(routeID) { - if pendingSkillIntent != nil { - return p.sendSlashError(ctx, sender, msg, slash.CodeUnsupportedSkillSlashContext) - } - headerifiedText := turn.FormatUserHeader(turn.UserMessageHeaderInput{ - MessageID: strings.TrimSpace(msg.Message.ID), - ChannelIdentityID: strings.TrimSpace(identity.ChannelIdentityID), - DisplayName: strings.TrimSpace(identity.DisplayName), - Channel: msg.Channel.String(), - ConversationType: strings.TrimSpace(msg.Conversation.Type), - ConversationName: strings.TrimSpace(msg.Conversation.Name), - Target: strings.TrimSpace(msg.ReplyTarget), - AttachmentPaths: collectAttachmentPaths(attachments), - Time: time.Now().UTC(), - }, text) - - switch inboundMode { - case ModeInject: - // Don't persist here — the injected message will be interleaved - // at the correct position within the round by - // interleaveInjectedMessages in storeRound. - injected := p.dispatcher.Inject(routeID, InjectMessage{ - Text: text, - Attachments: attachments, - HeaderifiedText: headerifiedText, - }) - if injected { - p.sendModeConfirmation(ctx, sender, msg, identity, "inject") - } else { - if p.logger != nil { - p.logger.Warn("inject failed (channel full), falling through to new stream", - slog.String("route_id", routeID)) - } - goto startStream - } - return nil - - case ModeQueue: - p.persistPassiveMessage(ctx, identity, msg, text, attachments, routeID, sessionID, eventID) - p.dispatcher.Enqueue(routeID, QueuedTask{ - Ctx: ctx, - Cfg: cfg, - Msg: msg, - Sender: sender, - Ident: identity, - Text: text, - Attachments: attachments, - }) - p.sendModeConfirmation(ctx, sender, msg, identity, "queue") - return nil - } - } - } - -startStream: - // Issue chat token for reply routing. chatToken := "" if p.jwtSecret != "" && strings.TrimSpace(msg.ReplyTarget) != "" { @@ -1142,19 +1069,6 @@ startStream: return err } - // Mark this route as active in the dispatcher so subsequent messages - // can be injected or queued. The dispatcher's queue is forwarded into - // the run handle after StartTurn. Parallel mode (/now) skips the - // dispatcher entirely — it must not interfere with the active flag or - // drain the queue of another stream. - var injectCh <-chan turn.InjectMessage - if p.dispatcher != nil && !isLocalChannelType(msg.Channel) && inboundMode != ModeParallel { - injectCh = p.dispatcher.MarkActive(routeID) - defer func() { - p.drainQueue(context.WithoutCancel(ctx), routeID) - }() - } - cmd := turn.StartTurnCommand{ SchemaVersion: 1, TeamID: cfg.TeamID, @@ -1215,7 +1129,7 @@ startStream: p.activeStreams.Store(streamKey, streamCancel) defer p.activeStreams.Delete(streamKey) - handle, startErr := p.turnSvc.StartTurn(streamCtx, cmd) + handle, startErr := p.startTurnWithBusyRetry(streamCtx, cmd) if startErr != nil { if errors.Is(startErr, turn.ErrDuplicateTurn) { // Platform webhook redelivery of an already-claimed message: @@ -1261,6 +1175,14 @@ startStream: } return startErr } + if errors.Is(startErr, turn.ErrTurnDeferred) { + if statusNotifier != nil { + if notifyErr := p.notifyProcessingCompleted(ctx, statusNotifier, cfg, msg, statusInfo, statusHandle); notifyErr != nil { + p.logProcessingStatusError("processing_completed", msg, identity, notifyErr) + } + } + return nil + } if p.logger != nil { p.logger.Error( "start turn failed", @@ -1285,37 +1207,6 @@ startStream: // running turn; the resolver attaches them at persist time. assets := &assetTracker{run: handle} - // Forward queued inject messages into the running turn. - if injectCh != nil { - go func() { - for { - select { - case m, ok := <-injectCh: - if !ok { - return - } - if injectErr := handle.Inject(streamCtx, m); injectErr != nil { - // The message is lost and this forwarder stops; later - // queued messages surface via drainQueue at turn end. - // Losing this silently would contradict the 👀 receipt - // the user already got. - if p.logger != nil { - p.logger.Warn( - "inject into running turn failed, message dropped", - slog.String("channel", msg.Channel.String()), - slog.String("route_id", routeID), - slog.Any("error", injectErr), - ) - } - return - } - case <-streamCtx.Done(): - return - } - } - }() - } - chunkCh, streamErrCh := handle.Events(), handle.Errs() var ( @@ -1499,6 +1390,63 @@ startStream: return nil } +// startTurnWithBusyRetry covers the race where a channel message arrives while +// an ask_user/tool response is committing and the same session is still busy. +// +// Only local channel types (web, cli) park the complete command in the +// follow-up queue. Their users observe the resulting run through the session +// runtime subscription, so nobody needs this call's handle. A platform +// channel delivers the reply by streaming this handle's events back to the +// platform; a run started later from the queue would have no consumer and its +// reply would never reach the user, so platform channels keep the bounded +// retry and surface ErrSessionBusy when it expires. +func (p *ChannelInboundProcessor) startTurnWithBusyRetry(ctx context.Context, cmd turn.StartTurnCommand) (turn.RunHandle, error) { + if p == nil || p.turnSvc == nil { + return nil, errors.New("channel inbound processor not configured") + } + deferrable := !cmd.NoDefer && isLocalChannelType(channel.ChannelType(cmd.CurrentChannel)) + deadline := time.NewTimer(turnBusyRetryWindow) + defer deadline.Stop() + delay := turnBusyRetryInitial + for { + handle, err := p.turnSvc.StartTurn(ctx, cmd) + if !errors.Is(err, turn.ErrSessionBusy) { + return handle, err + } + if deferred, ok := p.turnSvc.(turn.DeferredTurnService); ok && deferrable { + if queueErr := deferred.EnqueueDeferredTurn(ctx, cmd); queueErr == nil { + return nil, turn.ErrTurnDeferred + } + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + stopTimer(timer) + return nil, ctx.Err() + case <-deadline.C: + stopTimer(timer) + return nil, err + case <-timer.C: + } + if delay < turnBusyRetryMax { + delay *= 2 + if delay > turnBusyRetryMax { + delay = turnBusyRetryMax + } + } + } +} + +func stopTimer(timer *time.Timer) { + if timer == nil || timer.Stop() { + return + } + select { + case <-timer.C: + default: + } +} + func turnIdempotencyKey(channelType channel.ChannelType, routeID, externalMessageID string) string { externalMessageID = strings.TrimSpace(externalMessageID) if externalMessageID == "" { @@ -1511,31 +1459,72 @@ func turnIdempotencyKey(channelType channel.ChannelType, routeID, externalMessag }, ":") } -// sendModeConfirmation sends a lightweight acknowledgement to the user when -// their message is injected or queued rather than triggering a new stream. -func (p *ChannelInboundProcessor) sendModeConfirmation( +func queueCommandIdempotencyKey(channelType channel.ChannelType, routeID, externalMessageID, operation string) string { + key := turnIdempotencyKey(channelType, routeID, externalMessageID) + if key == "" { + return "" + } + return key + ":queue:" + strings.ToLower(strings.TrimSpace(operation)) +} + +func (p *ChannelInboundProcessor) handleQueueCommand( ctx context.Context, - _ channel.StreamReplySender, msg channel.InboundMessage, + sender channel.StreamReplySender, identity InboundIdentity, - mode string, -) { - target := strings.TrimSpace(msg.ReplyTarget) - sourceMessageID := strings.TrimSpace(msg.Message.ID) - if target == "" || sourceMessageID == "" { - return + routeID, sessionID, sessionType string, + invocation command.Invocation, +) error { + resource := strings.ToLower(strings.TrimSpace(invocation.Parsed.Resource)) + if p == nil || p.queueCommandHandler == nil { + return p.sendSlashError(ctx, sender, msg, QueueCommandCodeUnavailable) + } + if strings.TrimSpace(sessionID) == "" { + return p.sendSlashError(ctx, sender, msg, QueueCommandCodeNoActiveRun) + } + if strings.TrimSpace(sessionType) == sessionpkg.TypeDiscuss { + return p.sendSlashError(ctx, sender, msg, QueueCommandCodeUnsupported) + } + // A follow-up starts a run that the server owns; only local channels see + // that run's output through the session runtime subscription. A steer + // joins the run this channel is already streaming, so it stays available. + if resource == "queue" && !isLocalChannelType(msg.Channel) { + return p.sendSlashError(ctx, sender, msg, QueueCommandCodeFollowUpUnsupportedChannel) + } + invocationID := queueCommandIdempotencyKey(msg.Channel, routeID, msg.Message.ID, resource) + input := QueueCommandInput{ + BotID: strings.TrimSpace(identity.BotID), + SessionID: strings.TrimSpace(sessionID), + InvocationID: invocationID, + Text: strings.TrimSpace(invocation.Rest), + } + var err error + switch resource { + case "steer": + err = p.queueCommandHandler.EnqueueSteer(ctx, input) + case "queue": + err = p.queueCommandHandler.EnqueueFollowUp(ctx, input) + default: + return p.sendSlashError(ctx, sender, msg, QueueCommandCodeInvalid) } - if p.reactor != nil { - emoji := "👀" - if mode == "queue" { - emoji = "📋" + if err != nil { + code := QueueCommandErrorCode(err) + if code == "" { + code = QueueCommandCodeUnavailable + if p.logger != nil { + p.logger.Warn("queue command admission failed", + slog.String("bot_id", strings.TrimSpace(identity.BotID)), + slog.String("route_id", strings.TrimSpace(routeID)), + slog.String("operation", resource), + slog.Any("error", err)) + } } - _ = p.reactor.React(ctx, strings.TrimSpace(identity.BotID), msg.Channel, channel.ReactRequest{ - Target: target, - MessageID: sourceMessageID, - Emoji: emoji, - }) + return p.sendSlashError(ctx, sender, msg, code) } + if resource == "steer" { + return p.sendSlashNotice(ctx, sender, msg, "queue.steerAccepted") + } + return p.sendSlashNotice(ctx, sender, msg, "queue.accepted") } func (p *ChannelInboundProcessor) accessDeniedRole(ctx context.Context, identity InboundIdentity) string { @@ -1556,48 +1545,6 @@ func (p *ChannelInboundProcessor) accessDeniedRole(ctx context.Context, identity return role } -// drainQueue marks the route as done and processes any queued tasks. -func (p *ChannelInboundProcessor) drainQueue(ctx context.Context, routeID string) { - if p.dispatcher == nil { - return - } - result := p.dispatcher.MarkDone(routeID) - - for _, fn := range result.PendingPersists { - fn(ctx) - } - - for _, task := range result.QueuedTasks { - if p.logger != nil { - p.logger.Info("processing queued task", - slog.String("route_id", routeID), - slog.String("query", strings.TrimSpace(task.Text)), - ) - } - if err := p.HandleInbound(ctx, task.Cfg, task.Msg, task.Sender); err != nil { //nolint:contextcheck // ctx is already WithoutCancel from the defer caller - if p.logger != nil { - p.logger.Error("queued task processing failed", - slog.String("route_id", routeID), - slog.Any("error", err), - ) - } - } - } -} - -func collectAttachmentPaths(attachments []turn.Attachment) []string { - if len(attachments) == 0 { - return nil - } - paths := make([]string, 0, len(attachments)) - for _, att := range attachments { - if p := strings.TrimSpace(att.Path); p != "" { - paths = append(paths, p) - } - } - return paths -} - func shouldTriggerAssistantResponse(msg channel.InboundMessage) bool { if isDirectConversationType(msg.Conversation.Type) { return true @@ -1628,7 +1575,6 @@ func (p *ChannelInboundProcessor) classifyChannelSlash(text string, msg channel. Surface: slash.SurfaceChannel, IsGroup: !channel.IsPrivateConversationType(msg.Conversation.Type), Directed: isDirectedAtBot(msg), - SupportsMode: !isLocalChannelType(msg.Channel), BotAliases: channelSlashAliases(msg, identity), KnownCommand: func(resource string) bool { return isChannelControlResource(resource) || @@ -1639,7 +1585,7 @@ func (p *ChannelInboundProcessor) classifyChannelSlash(text string, msg channel. func isChannelControlResource(resource string) bool { switch strings.ToLower(strings.TrimSpace(resource)) { - case "start", "new", "stop", "status", "context", "approve", "reject", "respond": + case "start", "new", "stop", "status", "context", "approve", "reject", "respond", "queue", "steer": return true default: return false @@ -1746,6 +1692,17 @@ func (p *ChannelInboundProcessor) sendSlashError(ctx context.Context, sender cha }) } +func (p *ChannelInboundProcessor) sendSlashNotice(ctx context.Context, sender channel.StreamReplySender, msg channel.InboundMessage, key string) error { + out := applyMessageFormat(channel.Message{Text: p.localizer(ctx, msg.BotID).T(key)}, p.channelCaps(msg.Channel)) + if mid := strings.TrimSpace(msg.Message.ID); mid != "" { + out.Reply = &channel.ReplyRef{MessageID: mid} + } + return sender.Send(ctx, channel.OutboundMessage{ + Target: strings.TrimSpace(msg.ReplyTarget), + Message: out, + }) +} + func slashChannelMessage(t *i18n.Localizer, code string) string { if key := slashChannelMessageKey(code); key != "" { return t.T(key) @@ -1785,6 +1742,22 @@ func slashChannelMessageKey(code string) string { return "slash.error.permissionDenied" case slash.CodeReservedSkillMetadata: return "slash.error.reservedSkillMetadata" + case QueueCommandCodeNoActiveRun: + return "queue.noActiveRun" + case QueueCommandCodeOverloaded: + return "queue.overloaded" + case QueueCommandCodeUnavailable: + return "queue.unavailable" + case QueueCommandCodeConflict: + return "queue.conflict" + case QueueCommandCodeInvalid: + return "queue.invalid" + case QueueCommandCodeUnsupported: + return "queue.unsupported" + case QueueCommandCodeCapacity: + return "queue.capacity" + case QueueCommandCodeFollowUpUnsupportedChannel: + return "queue.followUpUnsupportedChannel" default: return "" } @@ -3828,32 +3801,27 @@ func splitFirstCommandField(text string) (head, tail string) { return text, "" } -func (p *ChannelInboundProcessor) streamToolApprovalCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, routeID string, approvalRunner ToolApprovalRunner, input turn.ToolApprovalResponse) error { - return p.streamContinuationCommand(ctx, msg, sender, identity, routeID, func(runCtx context.Context, eventCh chan<- json.RawMessage) error { +func (p *ChannelInboundProcessor) streamToolApprovalCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, _ string, approvalRunner ToolApprovalRunner, input turn.ToolApprovalResponse) error { + return p.streamContinuationCommand(ctx, msg, sender, identity, func(runCtx context.Context, eventCh chan<- json.RawMessage) error { return approvalRunner.RespondToolApproval(runCtx, input, eventCh) }) } -func (p *ChannelInboundProcessor) streamUserInputResponseCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, routeID string, userInputRunner UserInputRunner, input turn.UserInputResponse) error { - return p.streamContinuationCommand(ctx, msg, sender, identity, routeID, func(runCtx context.Context, eventCh chan<- json.RawMessage) error { +func (p *ChannelInboundProcessor) streamUserInputResponseCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, _ string, userInputRunner UserInputRunner, input turn.UserInputResponse) error { + return p.streamContinuationCommand(ctx, msg, sender, identity, func(runCtx context.Context, eventCh chan<- json.RawMessage) error { return userInputRunner.RespondUserInput(runCtx, input, eventCh) }) } type streamContinuationFunc func(context.Context, chan<- json.RawMessage) error -func (p *ChannelInboundProcessor) streamContinuationCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, routeID string, run streamContinuationFunc) error { +func (p *ChannelInboundProcessor) streamContinuationCommand(ctx context.Context, msg channel.InboundMessage, sender channel.StreamReplySender, identity InboundIdentity, run streamContinuationFunc) error { ctx, cancel := context.WithCancel(ctx) defer cancel() target := strings.TrimSpace(msg.ReplyTarget) if target == "" { return errors.New("reply target missing") } - routeID = strings.TrimSpace(routeID) - if routeID != "" && p.dispatcher != nil && !isLocalChannelType(msg.Channel) { - p.dispatcher.MarkActive(routeID) - defer p.drainQueue(context.WithoutCancel(ctx), routeID) - } sourceMessageID := strings.TrimSpace(msg.Message.ID) replyRef := &channel.ReplyRef{Target: target} if sourceMessageID != "" { diff --git a/internal/channel/inbound/channel_test.go b/internal/channel/inbound/channel_test.go index 1b5e2d71e..57cc993dc 100644 --- a/internal/channel/inbound/channel_test.go +++ b/internal/channel/inbound/channel_test.go @@ -41,6 +41,8 @@ type fakeChatGateway struct { resp fakeChatResponse err error gotReq turn.StartTurnCommand + startReqs []turn.StartTurnCommand + startErrors []error onChat func(turn.StartTurnCommand) userInputCalls int userInputInput turn.UserInputResponse @@ -53,6 +55,16 @@ type fakeChatGateway struct { advanceErr error } +type deferredFakeChatGateway struct { + fakeChatGateway + deferred []turn.StartTurnCommand +} + +func (f *deferredFakeChatGateway) EnqueueDeferredTurn(_ context.Context, cmd turn.StartTurnCommand) error { + f.deferred = append(f.deferred, cmd) + return nil +} + type fakeChatResponse struct { Messages []turn.ModelMessage } @@ -141,12 +153,18 @@ func TestRejectReservedSkillMetadataInInboundMessage(t *testing.T) { func (f *fakeChatGateway) StartTurn(_ context.Context, cmd turn.StartTurnCommand) (turn.RunHandle, error) { f.gotReq = cmd + f.startReqs = append(f.startReqs, cmd) if f.startErr != nil { return nil, f.startErr } if f.onChat != nil { f.onChat(cmd) } + if len(f.startErrors) > 0 { + err := f.startErrors[0] + f.startErrors = f.startErrors[1:] + return nil, err + } events := make(chan turn.Event, 1) errs := make(chan error, 1) if f.err != nil { @@ -329,6 +347,24 @@ type fakeSessionEnsurer struct { createErr error lastRouteID string lastSpec NewSessionSpec + createCalls int +} + +type fakeQueueCommandHandler struct { + steerInputs []QueueCommandInput + followUpInputs []QueueCommandInput + steerErr error + followUpErr error +} + +func (f *fakeQueueCommandHandler) EnqueueSteer(_ context.Context, input QueueCommandInput) error { + f.steerInputs = append(f.steerInputs, input) + return f.steerErr +} + +func (f *fakeQueueCommandHandler) EnqueueFollowUp(_ context.Context, input QueueCommandInput) error { + f.followUpInputs = append(f.followUpInputs, input) + return f.followUpErr } func (f *fakeSessionEnsurer) EnsureActiveSession(_ context.Context, _, routeID, _ string) (SessionResult, error) { @@ -348,6 +384,7 @@ func (f *fakeSessionEnsurer) GetActiveSession(_ context.Context, routeID string) } func (f *fakeSessionEnsurer) CreateNewSession(_ context.Context, _, routeID, _ string, spec NewSessionSpec) (SessionResult, error) { + f.createCalls++ f.lastRouteID = routeID f.lastSpec = spec if f.createErr != nil { @@ -888,7 +925,7 @@ func TestChannelInboundProcessorNativeUserInputWithoutPendingQuestionFallsThroug } } -func TestChannelInboundProcessorModeCommandBypassesTextFallback(t *testing.T) { +func TestChannelInboundProcessorRemovedModeCommandIsRejected(t *testing.T) { channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-1"}} chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "chat-1", RouteID: "route-1"}} gateway := &fakeChatGateway{resp: fakeChatResponse{Messages: []turn.ModelMessage{{Role: "assistant", Content: turn.NewTextContent("normal reply")}}}} @@ -900,11 +937,173 @@ func TestChannelInboundProcessorModeCommandBypassesTextFallback(t *testing.T) { Message: channel.Message{Text: "/btw side question"}, Sender: channel.Identity{SubjectID: "ext-1"}, Conversation: channel.Conversation{ID: "chat-1", Type: channel.ConversationTypePrivate}, } - if err := processor.HandleInbound(context.Background(), channel.ChannelConfig{TeamID: "team-test", BotID: "bot-1", ChannelType: msg.Channel}, msg, &fakeReplySender{}); err != nil { + sender := &fakeReplySender{} + if err := processor.HandleInbound(context.Background(), channel.ChannelConfig{TeamID: "team-test", BotID: "bot-1", ChannelType: msg.Channel}, msg, sender); err != nil { t.Fatalf("HandleInbound() error = %v", err) } if gateway.advanceCalls != 0 { - t.Fatalf("mode command advanced user input %d times", gateway.advanceCalls) + t.Fatalf("removed command advanced user input %d times", gateway.advanceCalls) + } + if len(sender.sent) != 1 || !strings.Contains(strings.ToLower(sender.sent[0].Message.PlainText()), "unknown") { + t.Fatalf("removed command response = %+v", sender.sent) + } +} + +func TestChannelInboundProcessorQueueCommandsUseResolvedSession(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-1"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-1", RouteID: "route-1"}} + gateway := &fakeChatGateway{} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1"}}) + queueHandler := &fakeQueueCommandHandler{} + processor.SetQueueCommandHandler(queueHandler) + for _, tc := range []struct { + channelType string + text string + wantSteer bool + wantPayload string + }{ + // Follow-ups start a server-owned run whose output only a local + // channel can observe; steers join the run this channel streams. + {channelType: "web", text: "/queue build the test", wantPayload: "build the test"}, + {channelType: "telegram", text: "/steer use bun", wantSteer: true, wantPayload: "use bun"}, + } { + t.Run(tc.channelType+" "+tc.text, func(t *testing.T) { + cfg := channel.ChannelConfig{TeamID: "team-test", ID: "cfg-1", BotID: "bot-1", ChannelType: channel.ChannelType(tc.channelType)} + msg := channel.InboundMessage{ + BotID: "bot-1", Channel: cfg.ChannelType, ReplyTarget: "target-id", + Message: channel.Message{ID: strings.ReplaceAll(tc.wantPayload, " ", "-"), Text: tc.text}, + Sender: channel.Identity{SubjectID: "ext-1"}, + Conversation: channel.Conversation{ID: "chat-1", Type: channel.ConversationTypePrivate}, + } + sender := &fakeReplySender{} + if err := processor.HandleInbound(context.Background(), cfg, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(sender.sent) != 1 || sender.sent[0].Message.Reply == nil || sender.sent[0].Message.Reply.MessageID != msg.Message.ID { + t.Fatalf("reply = %#v, want acknowledgment replying to command", sender.sent) + } + if gateway.gotReq.Query != "" || len(chatSvc.persistedIn) != 0 { + t.Fatalf("queue command entered normal chat: query=%q persisted=%d", gateway.gotReq.Query, len(chatSvc.persistedIn)) + } + if tc.wantSteer { + if len(queueHandler.steerInputs) == 0 { + t.Fatal("steer was not admitted") + } + input := queueHandler.steerInputs[len(queueHandler.steerInputs)-1] + if input.SessionID != "session-1" || input.Text != tc.wantPayload || input.InvocationID == "" { + t.Fatalf("steer input = %#v", input) + } + } else { + if len(queueHandler.followUpInputs) == 0 { + t.Fatal("follow-up was not admitted") + } + input := queueHandler.followUpInputs[len(queueHandler.followUpInputs)-1] + if input.SessionID != "session-1" || input.Text != tc.wantPayload || input.InvocationID == "" { + t.Fatalf("follow-up input = %#v", input) + } + } + }) + } +} + +func TestChannelInboundProcessorRejectsFollowUpQueueOnPlatformChannel(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-1"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-1", RouteID: "route-1"}} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, &fakeChatGateway{}, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1"}}) + queueHandler := &fakeQueueCommandHandler{} + processor.SetQueueCommandHandler(queueHandler) + cfg := channel.ChannelConfig{TeamID: "team-test", ID: "cfg-1", BotID: "bot-1", ChannelType: channel.ChannelType("telegram")} + msg := channel.InboundMessage{ + BotID: "bot-1", Channel: cfg.ChannelType, ReplyTarget: "target-id", + Message: channel.Message{ID: "queue-on-telegram", Text: "/queue build the test"}, + Sender: channel.Identity{SubjectID: "ext-1"}, + Conversation: channel.Conversation{ID: "chat-1", Type: channel.ConversationTypePrivate}, + } + sender := &fakeReplySender{} + if err := processor.HandleInbound(context.Background(), cfg, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(queueHandler.followUpInputs) != 0 || len(queueHandler.steerInputs) != 0 { + t.Fatalf("platform channel follow-up was admitted: %#v", queueHandler) + } + if len(sender.sent) != 1 || sender.sent[0].Message.PlainText() == "" { + t.Fatalf("reply = %#v, want an explanatory slash error", sender.sent) + } +} + +func TestChannelInboundProcessorQueueCommandNoSessionDoesNotCreateOne(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-1"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-1", RouteID: "route-1"}} + gateway := &fakeChatGateway{} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + ensurer := &fakeSessionEnsurer{activeErr: errors.New("no active session")} + processor.SetSessionEnsurer(ensurer) + queueHandler := &fakeQueueCommandHandler{} + processor.SetQueueCommandHandler(queueHandler) + msg := channel.InboundMessage{ + BotID: "bot-1", Channel: channel.ChannelType("telegram"), ReplyTarget: "target-id", + Message: channel.Message{ID: "queue-1", Text: "/queue later"}, Sender: channel.Identity{SubjectID: "ext-1"}, + Conversation: channel.Conversation{ID: "chat-1", Type: channel.ConversationTypePrivate}, + } + sender := &fakeReplySender{} + if err := processor.HandleInbound(context.Background(), channel.ChannelConfig{TeamID: "team-test", BotID: "bot-1", ChannelType: msg.Channel}, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(queueHandler.steerInputs) != 0 || len(queueHandler.followUpInputs) != 0 || gateway.gotReq.Query != "" || len(chatSvc.persistedIn) != 0 { + t.Fatalf("queue command without session admitted or started chat: queue=%#v/%#v query=%q persisted=%d", queueHandler.steerInputs, queueHandler.followUpInputs, gateway.gotReq.Query, len(chatSvc.persistedIn)) + } + if ensurer.createCalls != 0 { + t.Fatalf("queue command created a session %d times with spec %#v", ensurer.createCalls, ensurer.lastSpec) + } + if len(sender.sent) != 1 || !strings.Contains(strings.ToLower(sender.sent[0].Message.PlainText()), "no active") { + t.Fatalf("reply = %#v, want no-active-run feedback", sender.sent) + } +} + +func TestQueueCommandIdempotencyKeyScopesOperation(t *testing.T) { + steer := queueCommandIdempotencyKey(channel.ChannelType("telegram"), "route-1", "42", "steer") + retry := queueCommandIdempotencyKey(channel.ChannelType("telegram"), "route-1", "42", "steer") + queue := queueCommandIdempotencyKey(channel.ChannelType("telegram"), "route-1", "42", "queue") + if steer == "" || steer != retry || steer == queue { + t.Fatalf("keys = steer %q retry %q queue %q", steer, retry, queue) + } +} + +func TestChannelInboundProcessorQueueCommandsRejectDiscussSession(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-1"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-1", RouteID: "route-1"}} + gateway := &fakeChatGateway{} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1", Type: sessionpkg.TypeDiscuss}}) + queueHandler := &fakeQueueCommandHandler{} + processor.SetQueueCommandHandler(queueHandler) + cfg := channel.ChannelConfig{TeamID: "team-test", BotID: "bot-1", ChannelType: channel.ChannelType("telegram")} + + for _, text := range []string{"/queue continue later", "/steer use bun"} { + t.Run(text, func(t *testing.T) { + sender := &fakeReplySender{} + msg := channel.InboundMessage{ + BotID: "bot-1", Channel: cfg.ChannelType, ReplyTarget: "target-id", + Message: channel.Message{ID: strings.ReplaceAll(text, " ", "-"), Text: text}, + Sender: channel.Identity{SubjectID: "ext-1"}, + Conversation: channel.Conversation{ID: "chat-1", Type: channel.ConversationTypePrivate}, + } + if err := processor.HandleInbound(context.Background(), cfg, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(sender.sent) != 1 || !strings.Contains(strings.ToLower(sender.sent[0].Message.PlainText()), "not available") { + t.Fatalf("reply = %#v, want unsupported-session feedback", sender.sent) + } + }) + } + if len(queueHandler.steerInputs) != 0 || len(queueHandler.followUpInputs) != 0 || gateway.gotReq.Query != "" || len(chatSvc.persistedIn) != 0 { + t.Fatalf("discuss queue command admitted or entered chat: steer=%#v queue=%#v query=%q persisted=%d", queueHandler.steerInputs, queueHandler.followUpInputs, gateway.gotReq.Query, len(chatSvc.persistedIn)) } } @@ -1740,7 +1939,7 @@ func TestChannelInboundProcessorRejectsDirectSkillBeforeAutoDiscussSession(t *te } } -func TestChannelInboundProcessorRejectsDirectSkillBeforeActiveStreamInjection(t *testing.T) { +func TestChannelInboundProcessorRejectsUnresolvedDirectSkill(t *testing.T) { channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-skill-use-active"}} policySvc := &fakePolicyService{} chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "chat-skill-use-active", RouteID: "route-skill-use-active"}} @@ -1748,9 +1947,6 @@ func TestChannelInboundProcessorRejectsDirectSkillBeforeActiveStreamInjection(t processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, policySvc, "", 0) processor.SetACLService(&fakeChatACL{allowed: true}) processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1", Type: sessionpkg.TypeChat, Runtime: sessionpkg.RuntimeModel}}) - dispatcher := NewRouteDispatcher(slog.Default()) - dispatcher.MarkActive("route-skill-use-active") - processor.SetDispatcher(dispatcher) sender := &fakeReplySender{} msg := channel.InboundMessage{ @@ -1778,12 +1974,12 @@ func TestChannelInboundProcessorRejectsDirectSkillBeforeActiveStreamInjection(t if len(chatSvc.persistedIn) != 0 { t.Fatalf("skill slash should not persist before active-stream reject, got %+v", chatSvc.persistedIn) } - if len(sender.sent) != 1 || !strings.Contains(sender.sent[0].Message.PlainText(), "not supported") { - t.Fatalf("expected unsupported skill slash reply, got %+v", sender.sent) + if len(sender.sent) != 1 || !strings.Contains(sender.sent[0].Message.PlainText(), "not available") { + t.Fatalf("expected unavailable skill slash reply, got %+v", sender.sent) } } -func TestChannelInboundProcessorRejectsDirectSkillDuringContinuationStream(t *testing.T) { +func TestChannelInboundProcessorDoesNotUseRouteLocalContinuationLock(t *testing.T) { channelIdentitySvc := &fakeChannelIdentityService{ channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-skill-use-continuation"}, linkedUserIDs: map[string][]string{"channelIdentity-skill-use-continuation": {"user-1"}}, @@ -1797,7 +1993,6 @@ func TestChannelInboundProcessorRejectsDirectSkillDuringContinuationStream(t *te processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, policySvc, "", 0) processor.SetACLService(&fakeChatACL{allowed: true}) processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1", Type: sessionpkg.TypeChat, Runtime: sessionpkg.RuntimeModel}}) - processor.SetDispatcher(NewRouteDispatcher(slog.Default())) skillResolver := &fakeRequestedSkillResolver{items: []skillset.ResolvedSkill{{Name: "alpha", Content: "alpha skill content"}}} processor.SetRequestedSkillResolver(skillResolver) sender := &fakeReplySender{} @@ -1830,18 +2025,15 @@ func TestChannelInboundProcessorRejectsDirectSkillDuringContinuationStream(t *te if err := <-done; err != nil { t.Fatalf("respond HandleInbound() error = %v", err) } - if skillResolver.calls != 0 { - t.Fatalf("skill resolver calls = %d, want 0 during active continuation", skillResolver.calls) + if skillResolver.calls != 1 { + t.Fatalf("skill resolver calls = %d, want 1 without route-local dispatcher", skillResolver.calls) } - if gateway.gotReq.BotID != "" { - t.Fatalf("ordinary chat should not run during active continuation, got request %#v", gateway.gotReq) - } - if len(sender.sent) == 0 || !strings.Contains(sender.sent[0].Message.PlainText(), "not supported") { - t.Fatalf("expected unsupported skill slash reply, got %+v", sender.sent) + if gateway.gotReq.BotID != "bot-1" || gateway.gotReq.UserMessageKind != turn.UserMessageKindSkillActivation { + t.Fatalf("durable admission should receive the skill turn, got request %#v", gateway.gotReq) } } -func TestChannelInboundProcessorDirectSkillStartsStreamWithDispatcherInjectCh(t *testing.T) { +func TestChannelInboundProcessorDirectSkillStartsStream(t *testing.T) { channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-skill-use-dispatch"}} policySvc := &fakePolicyService{} chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "chat-skill-use-dispatch", RouteID: "route-skill-use-dispatch"}} @@ -1849,7 +2041,6 @@ func TestChannelInboundProcessorDirectSkillStartsStreamWithDispatcherInjectCh(t processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, policySvc, "", 0) processor.SetACLService(&fakeChatACL{allowed: true}) processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-1", Type: sessionpkg.TypeChat, Runtime: sessionpkg.RuntimeModel}}) - processor.SetDispatcher(NewRouteDispatcher(slog.Default())) skillResolver := &fakeRequestedSkillResolver{items: []skillset.ResolvedSkill{{ Name: "alpha", Content: "alpha skill content", @@ -3039,6 +3230,116 @@ func TestChannelInboundProcessorProcessingStatusSuccessLifecycle(t *testing.T) { } } +func TestChannelInboundProcessorRetriesBusyTurnWithoutDuplicatingDelivery(t *testing.T) { + notifier := &fakeProcessingStatusNotifier{ + startedHandle: channel.ProcessingStatusHandle{Token: "reaction-busy"}, + } + registry := channel.NewRegistry() + registry.MustRegister(&fakeProcessingStatusAdapter{notifier: notifier}) + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-busy"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-busy", RouteID: "route-busy"}} + gateway := &fakeChatGateway{ + startErrors: []error{turn.ErrSessionBusy}, + resp: fakeChatResponse{Messages: []turn.ModelMessage{ + {Role: "assistant", Content: turn.NewTextContent("delivered after continuation")}, + }}, + } + processor := NewChannelInboundProcessor(slog.Default(), registry, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-busy"}}) + sender := &fakeReplySender{} + cfg := channel.ChannelConfig{TeamID: "team-test", ID: "cfg-busy", BotID: "bot-busy", ChannelType: channel.ChannelType("feishu")} + msg := channel.InboundMessage{ + BotID: "bot-busy", Channel: channel.ChannelType("feishu"), + Message: channel.Message{ID: "msg-busy", Text: "ordinary message"}, + ReplyTarget: "target-busy", Sender: channel.Identity{SubjectID: "ext-busy"}, + Conversation: channel.Conversation{ID: "chat-busy", Type: channel.ConversationTypePrivate}, + } + + if err := processor.HandleInbound(context.Background(), cfg, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(gateway.startReqs) != 2 { + t.Fatalf("StartTurn calls = %d, want 2", len(gateway.startReqs)) + } + first, second := gateway.startReqs[0], gateway.startReqs[1] + if first.IdempotencyKey == "" || first.IdempotencyKey != second.IdempotencyKey { + t.Fatalf("retry changed idempotency key: %q -> %q", first.IdempotencyKey, second.IdempotencyKey) + } + if first.ExternalMessageID != second.ExternalMessageID || first.ThreadID != second.ThreadID { + t.Fatalf("retry changed delivery identity: first=%#v second=%#v", first, second) + } + if len(notifier.events) != 2 || notifier.events[0] != "started" || notifier.events[1] != "completed" { + t.Fatalf("processing status lifecycle = %#v, want started/completed", notifier.events) + } + if len(sender.sent) != 1 || sender.sent[0].Message.PlainText() != "delivered after continuation" { + t.Fatalf("outbound replies = %#v, want one model reply", sender.sent) + } +} + +// A web (local) ingress observes runs through the session runtime +// subscription, so a busy session parks its complete command in the follow-up +// queue and the inbound call returns without a reply. +func TestChannelInboundProcessorAcceptsBusyLocalTurnIntoRuntimeQueue(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-deferred"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-deferred", RouteID: "route-deferred"}} + gateway := &deferredFakeChatGateway{fakeChatGateway: fakeChatGateway{startErrors: []error{turn.ErrSessionBusy}}} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-deferred"}}) + sender := &fakeReplySender{} + msg := channel.InboundMessage{ + BotID: "bot-deferred", Channel: channel.ChannelType("web"), ReplyTarget: "target-deferred", + Message: channel.Message{ID: "msg-deferred", Text: "queued ordinary message"}, Sender: channel.Identity{SubjectID: "ext-deferred"}, + Conversation: channel.Conversation{ID: "chat-deferred", Type: channel.ConversationTypePrivate}, + } + if err := processor.HandleInbound(context.Background(), channel.ChannelConfig{TeamID: "team-test", BotID: msg.BotID, ChannelType: msg.Channel}, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(gateway.deferred) != 1 || len(sender.sent) != 0 { + t.Fatalf("deferred commands = %d, replies = %d", len(gateway.deferred), len(sender.sent)) + } + queued := gateway.deferred[0] + if queued.Query != msg.Message.Text || queued.ExternalMessageID != msg.Message.ID || queued.ThreadID != "session-deferred" || queued.IdempotencyKey == "" { + t.Fatalf("queued command lost delivery fields: %#v", queued) + } +} + +// A platform channel delivers the reply from this call's run handle. A run +// started later from the follow-up queue would have no consumer, so a busy +// session must keep retrying admission instead of parking the command. +func TestChannelInboundProcessorDoesNotDeferBusyPlatformTurn(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-platform"}} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{BotID: "bot-platform", RouteID: "route-platform"}} + gateway := &deferredFakeChatGateway{fakeChatGateway: fakeChatGateway{ + startErrors: []error{turn.ErrSessionBusy}, + resp: fakeChatResponse{Messages: []turn.ModelMessage{ + {Role: "assistant", Content: turn.NewTextContent("delivered after retry")}, + }}, + }} + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, &fakePolicyService{}, "", 0) + processor.SetACLService(&fakeChatACL{allowed: true}) + processor.SetSessionEnsurer(&fakeSessionEnsurer{activeSession: SessionResult{ID: "session-platform"}}) + sender := &fakeReplySender{} + msg := channel.InboundMessage{ + BotID: "bot-platform", Channel: channel.ChannelType("feishu"), ReplyTarget: "target-platform", + Message: channel.Message{ID: "msg-platform", Text: "ordinary message"}, Sender: channel.Identity{SubjectID: "ext-platform"}, + Conversation: channel.Conversation{ID: "chat-platform", Type: channel.ConversationTypePrivate}, + } + if err := processor.HandleInbound(context.Background(), channel.ChannelConfig{TeamID: "team-test", BotID: msg.BotID, ChannelType: msg.Channel}, msg, sender); err != nil { + t.Fatalf("HandleInbound() error = %v", err) + } + if len(gateway.deferred) != 0 { + t.Fatalf("platform turn was parked in the follow-up queue: %#v", gateway.deferred) + } + if len(gateway.startReqs) != 2 { + t.Fatalf("StartTurn calls = %d, want a retry after busy", len(gateway.startReqs)) + } + if len(sender.sent) != 1 || sender.sent[0].Message.PlainText() != "delivered after retry" { + t.Fatalf("outbound replies = %#v, want the model reply", sender.sent) + } +} + func TestChannelInboundProcessorProcessingStatusFailureLifecycle(t *testing.T) { notifier := &fakeProcessingStatusNotifier{ startedHandle: channel.ProcessingStatusHandle{Token: "reaction-2"}, diff --git a/internal/channel/inbound/continuation_output_test.go b/internal/channel/inbound/continuation_output_test.go index eaac87bbf..039fe8e39 100644 --- a/internal/channel/inbound/continuation_output_test.go +++ b/internal/channel/inbound/continuation_output_test.go @@ -15,7 +15,7 @@ func TestContinuationForwardsReplyAndInteractiveFollowUp(t *testing.T) { processor := NewChannelInboundProcessor(slog.Default(), nil, nil, nil, nil, nil, nil, "", 0) sender := &fakeReplySender{} msg := channel.InboundMessage{Channel: channel.ChannelType("telegram"), ReplyTarget: "test-chat", Message: channel.Message{ID: "answer"}} - err := processor.streamContinuationCommand(context.Background(), msg, sender, InboundIdentity{BotID: "bot"}, "", func(_ context.Context, ch chan<- json.RawMessage) error { + err := processor.streamContinuationCommand(context.Background(), msg, sender, InboundIdentity{BotID: "bot"}, func(_ context.Context, ch chan<- json.RawMessage) error { ch <- json.RawMessage(`{"type":"text_delta","delta":"收到你的答案"}`) ch <- json.RawMessage(`{"type":"user_input_request","toolName":"ask_user","toolCallId":"second-call","userInputId":"second-question","status":"pending","metadata":{"ui_payload":{"version":2,"questions":[{"id":"q1","kind":"text","text":"接下来做什么?"}]}}}`) ch <- json.RawMessage(`{"type":"agent_end","userInputId":"second-question","status":"pending"}`) @@ -44,7 +44,7 @@ func TestAcceptanceReceiptPrecedesFailedContinuation(t *testing.T) { accepted := false sender := decisionReplySender{StreamReplySender: sink, accepted: func(context.Context, string) { accepted = true }} msg := channel.InboundMessage{Channel: channel.ChannelType("telegram"), ReplyTarget: "chat"} - err := p.streamContinuationCommand(context.Background(), msg, sender, InboundIdentity{BotID: "bot"}, "", func(_ context.Context, ch chan<- json.RawMessage) error { + err := p.streamContinuationCommand(context.Background(), msg, sender, InboundIdentity{BotID: "bot"}, func(_ context.Context, ch chan<- json.RawMessage) error { ch <- json.RawMessage(`{"type":"decision_accepted","decision_id":"question"}`) return errors.New("SECRET transport failure after acceptance") }) diff --git a/internal/channel/inbound/dispatcher.go b/internal/channel/inbound/dispatcher.go deleted file mode 100644 index 0f8f44f1b..000000000 --- a/internal/channel/inbound/dispatcher.go +++ /dev/null @@ -1,310 +0,0 @@ -package inbound - -import ( - "context" - "log/slog" - "strings" - "sync" - "time" - - "github.com/felinics/memoh/internal/agent/turn" - "github.com/felinics/memoh/internal/channel" -) - -// InjectMessage is an alias for turn.InjectMessage, re-exported so -// callers within this package do not need to import the conversation package -// directly for inject-related types. -type InjectMessage = turn.InjectMessage - -// InboundMode determines how a new inbound message is handled when an agent -// stream is already active for the same route. -type InboundMode int - -const ( - // ModeInject (default, command /btw) injects the message into the active - // agent stream via the PrepareStep hook so the LLM sees it between tool - // rounds. When no stream is active, starts one normally. - ModeInject InboundMode = iota - // ModeParallel (command /now) starts a new agent stream immediately, - // running concurrently with any existing stream. - ModeParallel - // ModeQueue (command /next) queues the message and processes it after the - // current agent stream completes. - ModeQueue -) - -// QueuedTask holds everything needed to start an agent stream for a queued message. -type QueuedTask struct { - Ctx context.Context - Cfg channel.ChannelConfig - Msg channel.InboundMessage - Sender channel.StreamReplySender - Ident InboundIdentity - Text string - Attachments []turn.Attachment -} - -// PersistFunc is a deferred persistence closure called after the active stream -// completes (and its storeRound has run), ensuring correct created_at ordering. -type PersistFunc func(ctx context.Context) - -// routeState tracks in-flight agent activity for a single route. -type routeState struct { - mu sync.Mutex - activeOwners int - injectCh chan InjectMessage - queue []QueuedTask - pendingPersists []PersistFunc - lastUsed time.Time -} - -// RouteDispatcher manages per-route concurrency for inbound message processing. -// It decides whether a new message should be injected into an active stream, -// run in parallel, or be queued. -type RouteDispatcher struct { - mu sync.RWMutex - routes map[string]*routeState - logger *slog.Logger -} - -// NewRouteDispatcher creates a dispatcher with background cleanup. -func NewRouteDispatcher(logger *slog.Logger) *RouteDispatcher { - if logger == nil { - logger = slog.Default() - } - return &RouteDispatcher{ - routes: make(map[string]*routeState), - logger: logger.With(slog.String("component", "route_dispatcher")), - } -} - -const injectChBuffer = 16 - -func (d *RouteDispatcher) getOrCreate(routeID string) *routeState { - d.mu.RLock() - rs, ok := d.routes[routeID] - d.mu.RUnlock() - if ok { - return rs - } - d.mu.Lock() - defer d.mu.Unlock() - if rs, ok = d.routes[routeID]; ok { - return rs - } - rs = &routeState{ - injectCh: make(chan InjectMessage, injectChBuffer), - lastUsed: time.Now(), - } - d.routes[routeID] = rs - return rs -} - -// IsActive reports whether the given route has an active agent stream. -func (d *RouteDispatcher) IsActive(routeID string) bool { - routeID = strings.TrimSpace(routeID) - if routeID == "" { - return false - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - return rs.activeOwners > 0 -} - -// MarkActive acquires active ownership for a route and returns the shared -// inject channel that the agent should drain via PrepareStep. -func (d *RouteDispatcher) MarkActive(routeID string) <-chan InjectMessage { - routeID = strings.TrimSpace(routeID) - if routeID == "" { - return nil - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - rs.activeOwners++ - rs.lastUsed = time.Now() - return rs.injectCh -} - -// MarkDoneResult holds the data returned when a route transitions from active to idle. -type MarkDoneResult struct { - PendingPersists []PersistFunc - QueuedTasks []QueuedTask -} - -// MarkDone releases active ownership for a route. It returns pending persist -// functions and queued tasks only when the last active owner exits. -func (d *RouteDispatcher) MarkDone(routeID string) MarkDoneResult { - routeID = strings.TrimSpace(routeID) - if routeID == "" { - return MarkDoneResult{} - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - rs.lastUsed = time.Now() - if rs.activeOwners > 0 { - rs.activeOwners-- - } - if rs.activeOwners > 0 { - return MarkDoneResult{} - } - - drainInjectCh(rs.injectCh) - - var persists []PersistFunc - if len(rs.pendingPersists) > 0 { - persists = rs.pendingPersists - rs.pendingPersists = nil - } - - var tasks []QueuedTask - if len(rs.queue) > 0 { - tasks = rs.queue - rs.queue = nil - } - - return MarkDoneResult{PendingPersists: persists, QueuedTasks: tasks} -} - -// AddPendingPersist records a deferred persist closure to be executed after the -// active stream completes. This ensures injected messages get a created_at -// timestamp after the triggering message's round. -func (d *RouteDispatcher) AddPendingPersist(routeID string, fn PersistFunc) { - routeID = strings.TrimSpace(routeID) - if routeID == "" || fn == nil { - return - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - rs.pendingPersists = append(rs.pendingPersists, fn) -} - -// Inject sends a message to the inject channel of an active route. -// Returns true if the message was accepted (route is active and channel not full). -func (d *RouteDispatcher) Inject(routeID string, msg InjectMessage) bool { - routeID = strings.TrimSpace(routeID) - if routeID == "" { - return false - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - if rs.activeOwners == 0 { - return false - } - select { - case rs.injectCh <- msg: - if d.logger != nil { - d.logger.Info("message injected into active stream", - slog.String("route_id", routeID), - ) - } - return true - default: - if d.logger != nil { - d.logger.Warn("inject channel full, message dropped", - slog.String("route_id", routeID), - ) - } - return false - } -} - -// Enqueue adds a task to the route's queue for later processing. -func (d *RouteDispatcher) Enqueue(routeID string, task QueuedTask) { - routeID = strings.TrimSpace(routeID) - if routeID == "" { - return - } - rs := d.getOrCreate(routeID) - rs.mu.Lock() - defer rs.mu.Unlock() - rs.queue = append(rs.queue, task) - rs.lastUsed = time.Now() - if d.logger != nil { - d.logger.Info("message queued", - slog.String("route_id", routeID), - slog.Int("queue_size", len(rs.queue)), - ) - } -} - -// Cleanup removes idle route states older than maxAge. -func (d *RouteDispatcher) Cleanup(maxAge time.Duration) { - d.mu.Lock() - defer d.mu.Unlock() - cutoff := time.Now().Add(-maxAge) - for id, rs := range d.routes { - rs.mu.Lock() - idle := rs.activeOwners == 0 && rs.lastUsed.Before(cutoff) && len(rs.queue) == 0 - rs.mu.Unlock() - if idle { - delete(d.routes, id) - } - } -} - -func drainInjectCh(ch chan InjectMessage) { - for { - select { - case <-ch: - default: - return - } - } -} - -// DetectMode parses a message prefix to determine the inbound mode. -// Returns the mode and the text with the prefix stripped. -func DetectMode(text string) (InboundMode, string) { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return ModeInject, trimmed - } - - type modePrefix struct { - prefix string - mode InboundMode - } - prefixes := []modePrefix{ - {"/now ", ModeParallel}, - {"/next ", ModeQueue}, - {"/btw ", ModeInject}, - } - lower := strings.ToLower(trimmed) - for _, mp := range prefixes { - if strings.HasPrefix(lower, mp.prefix) { - return mp.mode, strings.TrimSpace(trimmed[len(mp.prefix):]) - } - } - // Exact match without trailing text (bare command) - barePrefixes := []modePrefix{ - {"/now", ModeParallel}, - {"/next", ModeQueue}, - {"/btw", ModeInject}, - } - for _, mp := range barePrefixes { - if lower == mp.prefix { - return mp.mode, "" - } - } - return ModeInject, trimmed -} - -// IsModeCommand reports whether the text is a mode-prefix command -// (/btw, /now, /next), so the generic command handler should skip it. -func IsModeCommand(text string) bool { - trimmed := strings.ToLower(strings.TrimSpace(text)) - if trimmed == "" { - return false - } - for _, prefix := range []string{"/now", "/next", "/btw"} { - if trimmed == prefix || strings.HasPrefix(trimmed, prefix+" ") || strings.HasPrefix(trimmed, prefix+"\t") { - return true - } - } - return false -} diff --git a/internal/channel/inbound/dispatcher_test.go b/internal/channel/inbound/dispatcher_test.go deleted file mode 100644 index 8772f6a2a..000000000 --- a/internal/channel/inbound/dispatcher_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package inbound - -import ( - "context" - "log/slog" - "sync" - "testing" - "time" - - "github.com/felinics/memoh/internal/command" -) - -func TestDetectMode(t *testing.T) { - tests := []struct { - input string - wantMode InboundMode - wantText string - }{ - {"hello world", ModeInject, "hello world"}, - {"/btw hello", ModeInject, "hello"}, - {"/now hello", ModeParallel, "hello"}, - {"/next hello", ModeQueue, "hello"}, - {"/BTW hello", ModeInject, "hello"}, - {"/NOW hello", ModeParallel, "hello"}, - {"/NEXT hello", ModeQueue, "hello"}, - {"/now", ModeParallel, ""}, - {"/next", ModeQueue, ""}, - {"/btw", ModeInject, ""}, - {" /now hello ", ModeParallel, "hello"}, - {"/unknown hello", ModeInject, "/unknown hello"}, - {"", ModeInject, ""}, - {"/new session", ModeInject, "/new session"}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - mode, text := DetectMode(tt.input) - if mode != tt.wantMode { - t.Errorf("DetectMode(%q) mode = %d, want %d", tt.input, mode, tt.wantMode) - } - if text != tt.wantText { - t.Errorf("DetectMode(%q) text = %q, want %q", tt.input, text, tt.wantText) - } - }) - } -} - -func TestIsStartCommand(t *testing.T) { - tests := []struct { - input string - want bool - }{ - {"/start", true}, - {"/start@MemohBot", true}, - // Telegram deep links: /start (and /start@bot ). - {"/start abc123", true}, - {"/start@MemohBot abc123", true}, - {"/start deep_link_payload", true}, - {"/new", false}, - {"/started", false}, - {"start", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - invocation, err := command.ParseInvocation(command.InvocationInput{ - Text: tt.input, - BotAliases: []string{"MemohBot"}, - }) - got := err == nil && invocationHasResource(&invocation, "start") - if got != tt.want { - t.Errorf("start invocation for %q = %v, want %v (error: %v)", tt.input, got, tt.want, err) - } - }) - } -} - -func TestIsModeCommand(t *testing.T) { - tests := []struct { - input string - want bool - }{ - {"/btw hello", true}, - {"/now hello", true}, - {"/next hello", true}, - {"/btw", true}, - {"/now", true}, - {"/next", true}, - {"/new", false}, - {"/fs list", false}, - {"hello", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := IsModeCommand(tt.input) - if got != tt.want { - t.Errorf("IsModeCommand(%q) = %v, want %v", tt.input, got, tt.want) - } - }) - } -} - -func TestRouteDispatcher_InjectWhenActive(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - if d.IsActive(routeID) { - t.Fatal("expected route to be inactive initially") - } - - injectCh := d.MarkActive(routeID) - if injectCh == nil { - t.Fatal("expected non-nil inject channel") - } - if !d.IsActive(routeID) { - t.Fatal("expected route to be active after MarkActive") - } - - msg := InjectMessage{Text: "hello", HeaderifiedText: "[User] hello"} - if !d.Inject(routeID, msg) { - t.Fatal("expected inject to succeed when route is active") - } - - select { - case got := <-injectCh: - if got.Text != "hello" { - t.Errorf("got text %q, want %q", got.Text, "hello") - } - default: - t.Fatal("expected message on inject channel") - } -} - -func TestRouteDispatcher_InjectWhenInactive(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - msg := InjectMessage{Text: "hello"} - if d.Inject(routeID, msg) { - t.Fatal("expected inject to fail when route is inactive") - } -} - -func TestRouteDispatcher_QueueAndDrain(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - - d.Enqueue(routeID, QueuedTask{Text: "task-1"}) - d.Enqueue(routeID, QueuedTask{Text: "task-2"}) - - result := d.MarkDone(routeID) - if len(result.QueuedTasks) != 2 { - t.Fatalf("expected 2 queued tasks, got %d", len(result.QueuedTasks)) - } - if result.QueuedTasks[0].Text != "task-1" || result.QueuedTasks[1].Text != "task-2" { - t.Errorf("unexpected task order: %v", result.QueuedTasks) - } - if d.IsActive(routeID) { - t.Fatal("expected route to be inactive after MarkDone") - } -} - -func TestRouteDispatcher_OverlappingActiveOwners(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - d.MarkActive(routeID) - d.Enqueue(routeID, QueuedTask{Text: "queued"}) - d.AddPendingPersist(routeID, func(context.Context) {}) - - first := d.MarkDone(routeID) - if len(first.QueuedTasks) != 0 { - t.Fatalf("expected no queued tasks while an owner remains active, got %d", len(first.QueuedTasks)) - } - if len(first.PendingPersists) != 0 { - t.Fatalf("expected no pending persists while an owner remains active, got %d", len(first.PendingPersists)) - } - if !d.IsActive(routeID) { - t.Fatal("expected route to stay active until the last owner exits") - } - if !d.Inject(routeID, InjectMessage{Text: "still active"}) { - t.Fatal("expected inject to remain available while an owner remains active") - } - - second := d.MarkDone(routeID) - if d.IsActive(routeID) { - t.Fatal("expected route to be inactive after the last owner exits") - } - if len(second.QueuedTasks) != 1 || second.QueuedTasks[0].Text != "queued" { - t.Fatalf("expected queued task on final release, got %v", second.QueuedTasks) - } - if len(second.PendingPersists) != 1 { - t.Fatalf("expected pending persist on final release, got %d", len(second.PendingPersists)) - } -} - -func TestRouteDispatcher_MarkDoneReturnsNilWhenEmpty(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - result := d.MarkDone(routeID) - if result.QueuedTasks != nil { - t.Fatalf("expected nil queued tasks, got %v", result.QueuedTasks) - } - if result.PendingPersists != nil { - t.Fatalf("expected nil pending persists, got %v", result.PendingPersists) - } -} - -func TestRouteDispatcher_ConcurrentInject(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - injectCh := d.MarkActive(routeID) - - const numMessages = 10 - var wg sync.WaitGroup - wg.Add(numMessages) - for i := 0; i < numMessages; i++ { - go func() { - defer wg.Done() - d.Inject(routeID, InjectMessage{Text: "msg"}) - }() - } - wg.Wait() - - count := 0 - for { - select { - case <-injectCh: - count++ - default: - goto done - } - } -done: - if count != numMessages { - t.Errorf("expected %d messages, got %d", numMessages, count) - } -} - -func TestRouteDispatcher_ParallelBypass(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - - // In parallel mode, the caller does not interact with the dispatcher - // at all — it just starts a new stream directly. Verify the route - // stays active without interference. - if !d.IsActive(routeID) { - t.Fatal("expected route to still be active") - } - - d.MarkDone(routeID) - if d.IsActive(routeID) { - t.Fatal("expected route to be inactive after MarkDone") - } -} - -func TestRouteDispatcher_Cleanup(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - - d.MarkActive("route-1") - d.MarkDone("route-1") - - d.MarkActive("route-2") - - d.mu.RLock() - initialCount := len(d.routes) - d.mu.RUnlock() - if initialCount != 2 { - t.Fatalf("expected 2 routes, got %d", initialCount) - } - - d.Cleanup(0) - - d.mu.RLock() - afterCount := len(d.routes) - d.mu.RUnlock() - - // route-1 is idle → cleaned up; route-2 is active → kept - if afterCount != 1 { - t.Fatalf("expected 1 route after cleanup, got %d", afterCount) - } - if d.IsActive("route-2") != true { - t.Fatal("expected route-2 to still be active") - } -} - -func TestRouteDispatcher_InjectChannelFull(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - - // Fill the inject channel to capacity - for i := 0; i < injectChBuffer; i++ { - if !d.Inject(routeID, InjectMessage{Text: "fill"}) { - t.Fatalf("expected inject %d to succeed", i) - } - } - - // Next inject should fail (channel full) - if d.Inject(routeID, InjectMessage{Text: "overflow"}) { - t.Fatal("expected inject to fail when channel is full") - } -} - -func TestRouteDispatcher_QueueWhenInactive(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - // Enqueue without marking active — still stores in queue - d.Enqueue(routeID, QueuedTask{Text: "task-1"}) - - // MarkActive then MarkDone should return the queued task - d.MarkActive(routeID) - result := d.MarkDone(routeID) - if len(result.QueuedTasks) != 1 { - t.Fatalf("expected 1 queued task, got %d", len(result.QueuedTasks)) - } -} - -func TestRouteDispatcher_MultipleMarkActive(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - ch1 := d.MarkActive(routeID) - ch2 := d.MarkActive(routeID) - - if ch1 == nil || ch2 == nil { - t.Fatal("expected non-nil channels") - } - - _ = time.Now() -} - -func TestRouteDispatcher_PendingPersistOrder(t *testing.T) { - d := NewRouteDispatcher(slog.Default()) - routeID := "route-1" - - d.MarkActive(routeID) - - var order []string - d.AddPendingPersist(routeID, func(_ context.Context) { - order = append(order, "B") - }) - d.AddPendingPersist(routeID, func(_ context.Context) { - order = append(order, "C") - }) - - result := d.MarkDone(routeID) - if len(result.PendingPersists) != 2 { - t.Fatalf("expected 2 pending persists, got %d", len(result.PendingPersists)) - } - - // Execute persists — they should run in insertion order (B then C) - for _, fn := range result.PendingPersists { - fn(context.Background()) - } - if len(order) != 2 || order[0] != "B" || order[1] != "C" { - t.Errorf("expected [B C], got %v", order) - } -} diff --git a/internal/channel/inbound/queue_command.go b/internal/channel/inbound/queue_command.go new file mode 100644 index 000000000..aa5d8d9b7 --- /dev/null +++ b/internal/channel/inbound/queue_command.go @@ -0,0 +1,78 @@ +package inbound + +import ( + "context" + "errors" +) + +const ( + // QueueCommandCodeNoActiveRun means the route has no active run that can + // accept a queue item. It is deliberately shared by an absent active + // session and a session whose run ended between route lookup and admission. + QueueCommandCodeNoActiveRun = "queue_no_active_run" + QueueCommandCodeOverloaded = "queue_admission_overloaded" + QueueCommandCodeUnavailable = "queue_admission_unavailable" + QueueCommandCodeConflict = "queue_invocation_conflict" + QueueCommandCodeInvalid = "queue_request_invalid" + QueueCommandCodeUnsupported = "queue_unsupported_session" + QueueCommandCodeCapacity = "queue_capacity_exceeded" + // QueueCommandCodeFollowUpUnsupportedChannel means the channel cannot + // receive the reply of a run that the server starts from the follow-up + // queue: platform channels deliver replies from the inbound call's run + // handle, which a queued run does not have. + QueueCommandCodeFollowUpUnsupportedChannel = "queue_follow_up_unsupported_channel" +) + +// QueueCommandInput contains only facts derived by the channel boundary. The +// session is resolved from the current route; callers cannot select a run or +// supply queue provenance. +type QueueCommandInput struct { + BotID string `json:"bot_id"` + SessionID string `json:"session_id"` + InvocationID string `json:"invocation_id"` + Text string `json:"text"` +} + +// QueueCommandHandler is the narrow live-queue port used by channel slash +// controls. The embedded Server uses a local adapter; split Channel uses the +// authenticated server-runtime RPC client. +type QueueCommandHandler interface { + EnqueueSteer(context.Context, QueueCommandInput) error + EnqueueFollowUp(context.Context, QueueCommandInput) error +} + +// QueueCommandError carries a stable, user-safe error code across the local +// and split-runtime boundaries. It intentionally contains no database or RPC +// diagnostic text. +type QueueCommandError struct{ Code string } + +func (e QueueCommandError) Error() string { return e.Code } + +func NewQueueCommandError(code string) error { return QueueCommandError{Code: code} } + +func QueueCommandErrorCode(err error) string { + var queueErr QueueCommandError + if !errors.As(err, &queueErr) { + return "" + } + return NormalizeQueueCommandCode(queueErr.Code) +} + +// NormalizeQueueCommandCode accepts only the stable error vocabulary allowed +// to cross a channel boundary. It is used by the split-runtime RPC client, +// where the generic RPC transport reconstructs a public error from its code. +func NormalizeQueueCommandCode(code string) string { + switch code { + case QueueCommandCodeNoActiveRun, + QueueCommandCodeOverloaded, + QueueCommandCodeUnavailable, + QueueCommandCodeConflict, + QueueCommandCodeInvalid, + QueueCommandCodeUnsupported, + QueueCommandCodeCapacity, + QueueCommandCodeFollowUpUnsupportedChannel: + return code + default: + return "" + } +} diff --git a/internal/chat/message/runtime_fence_postgres_integration_test.go b/internal/chat/message/runtime_fence_postgres_integration_test.go index 0a0cc4af4..a861b24f9 100644 --- a/internal/chat/message/runtime_fence_postgres_integration_test.go +++ b/internal/chat/message/runtime_fence_postgres_integration_test.go @@ -246,6 +246,76 @@ func TestPostgresRuntimeFenceAgentStepCompleteAndInterruptedWrites(t *testing.T) } } +// Named for the ^TestPostgresRuntimeFence filter used by the durable runtime +// CI job. This covers the retry/edit persistence port consumed by the queue +// coordinator: step deltas stay hidden until the true final boundary publishes +// the replacement. +func TestPostgresRuntimeFenceAgentReplacementStepsFinalizeVisibility(t *testing.T) { + ctx := context.Background() + pool := openRuntimeFencePostgresPool(t, ctx) + botID, sessionID := createRuntimeFenceFixtures(t, ctx, pool) + queries := dbsqlc.New(pool) + storeQueries := postgresstore.NewQueriesWithPool(pool, queries) + service := NewService(nil, storeQueries) + + user, err := service.Persist(ctx, PersistInput{ + BotID: botID.String(), SessionID: sessionID.String(), Role: "user", + Content: []byte(`{"role":"user","content":"original request"}`), + }) + if err != nil { + t.Fatalf("persist original user: %v", err) + } + oldAssistant, err := service.Persist(ctx, PersistInput{ + BotID: botID.String(), SessionID: sessionID.String(), Role: "assistant", + Content: []byte(`{"role":"assistant","content":"original answer"}`), TurnRequestMessageID: user.ID, + }) + if err != nil { + t.Fatalf("persist original assistant: %v", err) + } + oldTurn, err := service.GetVisibleTurnByMessage(ctx, sessionID.String(), oldAssistant.ID) + if err != nil { + t.Fatalf("load original turn: %v", err) + } + + token := acquireRuntimeFenceToken(t, ctx, queries, botID, sessionID) + runID, replacementTurnID := uuid.New(), uuid.NewString() + replacementPosition := oldTurn.Position + if _, err := pool.Exec(ctx, ` + INSERT INTO session_runs + (run_id, bot_id, session_id, invocation_id, turn_id, turn_position, state, + input_json, input_fingerprint, owner_id, fencing_token, owner_since, live_generation) + VALUES ($1, $2, $3, $4, $5, $6, 'running', '{}'::jsonb, 'replacement-test', 'test-owner', $7, now(), 'test')`, + runID, botID, sessionID, uuid.NewString(), replacementTurnID, replacementPosition, token); err != nil { + t.Fatalf("create replacement run: %v", err) + } + owner := runtimefence.WithContext(ctx, runtimefence.Fence{BotID: botID.String(), SessionID: sessionID.String(), Token: token}) + step := AgentStep{RunID: runID.String(), Messages: []PersistInput{{ + BotID: botID.String(), SessionID: sessionID.String(), RunID: runID.String(), Role: "assistant", + Content: []byte(`{"role":"assistant","content":"replacement answer"}`), + TurnRequestMessageID: user.ID, SkipHistoryTurn: true, + }}} + hidden, err := service.PersistAgentReplacementStep(owner, step) + if err != nil { + t.Fatalf("persist hidden replacement step: %v", err) + } + visible, err := service.ListBySession(ctx, sessionID.String()) + if err != nil || len(visible) != 2 || visible[1].ID != oldAssistant.ID { + t.Fatalf("visible history before finalization = %#v, %v", visible, err) + } + + replacement := TurnReplacement{ + OldTurnID: oldTurn.ID, ReplacementTurnID: replacementTurnID, + ReplacementTurnPosition: &replacementPosition, RequestMessageID: user.ID, Reason: "retry", + } + if err := service.FinalizeAgentReplacement(owner, sessionID.String(), replacement, user.ID, hidden[0].ID); err != nil { + t.Fatalf("finalize replacement history: %v", err) + } + visible, err = service.ListBySession(ctx, sessionID.String()) + if err != nil || len(visible) != 2 || visible[0].ID != user.ID || visible[1].ID != hidden[0].ID { + t.Fatalf("visible history after finalization = %#v, %v", visible, err) + } +} + func interruptedCheckpointInput(botID, sessionID pgtype.UUID, runID uuid.UUID, requestMessageID, text string) PersistInput { return PersistInput{ BotID: botID.String(), SessionID: sessionID.String(), RunID: runID.String(), Role: "assistant", diff --git a/internal/chat/message/service.go b/internal/chat/message/service.go index 2b052cb6d..8c4fa8b95 100644 --- a/internal/chat/message/service.go +++ b/internal/chat/message/service.go @@ -941,16 +941,10 @@ func resolveRuntimeSnapshotWithQueries(ctx context.Context, queries dbstore.Quer return sessionMode, runtimeType } +// Database rows already carry the backfilled, constrained descriptor. Legacy +// request/import normalization belongs at those input boundaries. func sessionSnapshotFromRow(row sqlc.BotSession) (string, string) { - sessionMode := normalizeSessionMode(row.SessionMode) - if sessionMode == "" { - sessionMode = legacySessionMode(row.Type) - } - runtimeType := normalizeRuntimeType(row.RuntimeType) - if runtimeType == "" { - runtimeType = legacyRuntimeType(row.Type) - } - return sessionMode, runtimeType + return row.SessionMode, row.RuntimeType } func normalizeSessionMode(mode string) string { @@ -974,24 +968,6 @@ func normalizeRuntimeType(runtimeType string) string { return string(kind) } -func legacySessionMode(typ string) string { - switch strings.TrimSpace(typ) { - case "acp_agent": - return "chat" - case "discuss", "schedule", "subagent": - return strings.TrimSpace(typ) - default: - return "chat" - } -} - -func legacyRuntimeType(typ string) string { - if strings.TrimSpace(typ) == "acp_agent" { - return "acp_agent" - } - return "model" -} - // List returns all messages for a bot. func (s *DBService) List(ctx context.Context, botID string) ([]Message, error) { pgBotID, err := dbpkg.ParseUUID(botID) diff --git a/internal/chat/message/step_commit.go b/internal/chat/message/step_commit.go index dd10a42ea..7ae337409 100644 --- a/internal/chat/message/step_commit.go +++ b/internal/chat/message/step_commit.go @@ -25,28 +25,48 @@ type agentStepQueries interface { // Complete steps precede abort intent; interrupted checkpoints remain writable // until terminal finalization for cancellation paths without recorded intent. func (s *DBService) PersistAgentStep(ctx context.Context, step AgentStep) ([]Message, error) { - if s == nil || s.queries == nil { - return nil, errors.New("message service is not configured") - } - if len(step.Messages) == 0 { - return nil, errors.New("agent step requires messages") + return s.persistAgentStep(ctx, step, false) +} + +// PersistAgentReplacementStep keeps retry/edit output hidden until the true +// final boundary. Both step kinds use the same fenced persistence transaction. +func (s *DBService) PersistAgentReplacementStep(ctx context.Context, step AgentStep) ([]Message, error) { + return s.persistAgentStep(ctx, step, true) +} + +func (s *DBService) persistAgentStep(ctx context.Context, step AgentStep, replacement bool) ([]Message, error) { + botID, sessionID, err := validateAgentStepMode(ctx, s, step, replacement) + if err != nil { + return nil, err } - runID := strings.TrimSpace(step.RunID) - botID := strings.TrimSpace(step.Messages[0].BotID) - sessionID := strings.TrimSpace(step.Messages[0].SessionID) - if runID == "" || botID == "" || sessionID == "" { - return nil, errors.New("agent step requires run, bot, and session ids") + var persisted []Message + err = runtimefence.InTransaction(ctx, s.queries, botID, sessionID, func(queries dbstore.Queries) error { + var txErr error + persisted, txErr = s.persistAgentStepTx(ctx, queries, step, replacement) + return txErr + }) + if err != nil { + return nil, err } - for _, input := range step.Messages { - if input.SkipHistoryTurn || strings.TrimSpace(input.RunID) != runID || - strings.TrimSpace(input.BotID) != botID || strings.TrimSpace(input.SessionID) != sessionID { - return nil, errors.New("agent step messages must share one visible run and session") + if !replacement { + for _, message := range persisted { + s.publishMessageCreated(message) } } - fence, ok := runtimefence.FromContext(ctx) - if !ok { - return nil, errors.New("agent step requires a runtime persistence fence") + return persisted, nil +} + +func (s *DBService) persistAgentStepTx(ctx context.Context, queries dbstore.Queries, step AgentStep, replacement bool) ([]Message, error) { + if _, _, err := validateAgentStepMode(ctx, s, step, replacement); err != nil { + return nil, err } + if queries == nil { + return nil, errors.New("persistence transaction is not configured") + } + runID := strings.TrimSpace(step.RunID) + botID := strings.TrimSpace(step.Messages[0].BotID) + sessionID := strings.TrimSpace(step.Messages[0].SessionID) + fence, _ := runtimefence.FromContext(ctx) pgRunID, err := dbpkg.ParseUUID(runID) if err != nil { return nil, fmt.Errorf("invalid agent step run id: %w", err) @@ -59,48 +79,89 @@ func (s *DBService) PersistAgentStep(ctx context.Context, step AgentStep) ([]Mes if err != nil { return nil, fmt.Errorf("invalid agent step session id: %w", err) } + writer, ok := queries.(agentStepQueries) + if !ok { + return nil, errors.New("persistence store does not support agent step writes") + } + params := sqlc.LockSessionRunForAgentStepCommitParams{ + RunID: pgRunID, BotID: pgBotID, SessionID: pgSessionID, + FencingToken: fence.Token, Interrupted: step.Interrupted, + } + _, lockErr := writer.LockSessionRunForAgentStepCommit(ctx, params) + if errors.Is(lockErr, pgx.ErrNoRows) { + return nil, ErrAgentStepNotWritable + } else if lockErr != nil { + return nil, fmt.Errorf("lock session run for agent step: %w", lockErr) + } - var persisted []Message - err = runtimefence.InTransaction(ctx, s.queries, botID, sessionID, func(queries dbstore.Queries) error { - writer, ok := queries.(agentStepQueries) - if !ok { - return errors.New("persistence store does not support agent step writes") + txService := *s + txService.queries = queries + txService.publisher = nil + turnRequestMessageID := strings.TrimSpace(step.Messages[0].TurnRequestMessageID) + persisted := make([]Message, 0, len(step.Messages)) + for _, original := range step.Messages { + input := original + input.TurnRequestMessageID = turnRequestMessageID + message, err := txService.persist(ctx, input) + if err != nil { + return nil, err } - params := sqlc.LockSessionRunForAgentStepCommitParams{ - RunID: pgRunID, BotID: pgBotID, SessionID: pgSessionID, - FencingToken: fence.Token, Interrupted: step.Interrupted, - } - _, lockErr := writer.LockSessionRunForAgentStepCommit(ctx, params) - if errors.Is(lockErr, pgx.ErrNoRows) { - return ErrAgentStepNotWritable - } else if lockErr != nil { - return fmt.Errorf("lock session run for agent step: %w", lockErr) + if strings.EqualFold(strings.TrimSpace(input.Role), "user") { + turnRequestMessageID = message.ID } + persisted = append(persisted, message) + } + return persisted, nil +} +// FinalizeAgentReplacement atomically selects the completed replacement turn. +// Queue coordination is transient and does not own this database transaction. +func (s *DBService) FinalizeAgentReplacement(ctx context.Context, sessionID string, replacement TurnReplacement, requestMessageID, assistantMessageID string) error { + if s == nil || s.queries == nil { + return errors.New("replacement persistence is not configured") + } + fence, ok := runtimefence.FromContext(ctx) + if !ok { + return errors.New("agent replacement requires a runtime persistence fence") + } + return runtimefence.InTransaction(ctx, s.queries, fence.BotID, sessionID, func(queries dbstore.Queries) error { + requestMessageID = strings.TrimSpace(requestMessageID) + assistantMessageID = strings.TrimSpace(assistantMessageID) + if requestMessageID == "" || assistantMessageID == "" { + return errors.New("agent replacement requires request and assistant message ids") + } txService := *s txService.queries = queries txService.publisher = nil - turnRequestMessageID := strings.TrimSpace(step.Messages[0].TurnRequestMessageID) - persisted = make([]Message, 0, len(step.Messages)) - for _, original := range step.Messages { - input := original - input.TurnRequestMessageID = turnRequestMessageID - message, err := txService.persist(ctx, input) - if err != nil { - return err - } - if strings.EqualFold(strings.TrimSpace(input.Role), "user") { - turnRequestMessageID = message.ID - } - persisted = append(persisted, message) - } - return nil + replacement.RequestMessageID = requestMessageID + return txService.replacePersistedRound(ctx, strings.TrimSpace(sessionID), []Message{ + {ID: requestMessageID, Role: "user"}, + {ID: assistantMessageID, Role: "assistant"}, + }, replacement) }) - if err != nil { - return nil, err +} + +func validateAgentStepMode(ctx context.Context, s *DBService, step AgentStep, replacement bool) (string, string, error) { + if s == nil || s.queries == nil { + return "", "", errors.New("message service is not configured") } - for _, message := range persisted { - s.publishMessageCreated(message) + if len(step.Messages) == 0 { + return "", "", errors.New("agent step requires messages") } - return persisted, nil + runID := strings.TrimSpace(step.RunID) + botID := strings.TrimSpace(step.Messages[0].BotID) + sessionID := strings.TrimSpace(step.Messages[0].SessionID) + if runID == "" || botID == "" || sessionID == "" { + return "", "", errors.New("agent step requires run, bot, and session ids") + } + for _, input := range step.Messages { + if input.SkipHistoryTurn != replacement || strings.TrimSpace(input.RunID) != runID || + strings.TrimSpace(input.BotID) != botID || strings.TrimSpace(input.SessionID) != sessionID { + return "", "", errors.New("agent step messages must share one run, session, and history visibility mode") + } + } + if _, ok := runtimefence.FromContext(ctx); !ok { + return "", "", errors.New("agent step requires a runtime persistence fence") + } + return botID, sessionID, nil } diff --git a/internal/chat/message/types.go b/internal/chat/message/types.go index ebfdb7968..b343a2860 100644 --- a/internal/chat/message/types.go +++ b/internal/chat/message/types.go @@ -206,6 +206,13 @@ type AgentStepPersister interface { PersistAgentStep(ctx context.Context, step AgentStep) ([]Message, error) } +// AgentReplacementPersister owns the fenced database transactions for hidden +// retry/edit steps and their final visible-turn replacement. +type AgentReplacementPersister interface { + PersistAgentReplacementStep(context.Context, AgentStep) ([]Message, error) + FinalizeAgentReplacement(context.Context, string, TurnReplacement, string, string) error +} + // Service defines message read/write behavior. type Service interface { Writer diff --git a/internal/command/menu.go b/internal/command/menu.go index 68e212919..fa1047c93 100644 --- a/internal/command/menu.go +++ b/internal/command/menu.go @@ -23,6 +23,8 @@ func MenuCommands(t *i18n.Localizer) []MenuCommand { {"help", t.T("menu.help")}, {"new", t.T("menu.new")}, {"stop", t.T("menu.stop")}, + {"queue", t.T("menu.queue")}, + {"steer", t.T("menu.steer")}, {"status", t.T("menu.status")}, {"context", t.T("menu.context")}, {"model", t.T("menu.model")}, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aa5166338..9f8ab7f1b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -351,6 +351,7 @@ owner_lease_ttl = "45s" [session_runtime.redis] url = "redis://redis.example:6379/2" key_prefix = "test:runtime:" + `) if err := os.WriteFile(configPath, data, 0o600); err != nil { t.Fatalf("write config: %v", err) diff --git a/internal/contextview/steer_continuation_test.go b/internal/contextview/steer_continuation_test.go new file mode 100644 index 000000000..67146d84b --- /dev/null +++ b/internal/contextview/steer_continuation_test.go @@ -0,0 +1,63 @@ +package contextview + +import ( + "context" + "strings" + "sync/atomic" + "testing" + + sdk "github.com/felinics/twilight/sdk" + + contextfrag "github.com/felinics/memoh/internal/agent/context/fragment" + native "github.com/felinics/memoh/internal/agent/runtime/native" +) + +func TestFinalSteerSurvivesProductionContextCompilationAndStepCapture(t *testing.T) { + var continueAfter atomic.Bool + var next []sdk.Message + var indexes []int + var captured [][]sdk.Message + provider := &envelopeProbeProvider{handler: func(call int, params sdk.GenerateParams) (*sdk.GenerateResult, error) { + if call == 2 { + var text strings.Builder + for _, message := range params.Messages { + if message.Role == sdk.MessageRoleUser { + text.Reset() + for _, part := range message.Content { + if p, ok := part.(sdk.TextPart); ok { + text.WriteString(p.Text) + } + } + } + } + if !strings.Contains(text.String(), "new direction") { + t.Errorf("typed context dropped the steer: %q", text.String()) + } + } + return &sdk.GenerateResult{Text: "answer", FinishReason: sdk.FinishReasonStop}, nil + }} + cfg := native.RunConfig{ + Model: &sdk.Model{ID: "model", Provider: provider}, Messages: []sdk.Message{sdk.UserMessage("original")}, + ContextSourceFrags: []contextfrag.ContextFrag{currentMessageFrag("message.000", "original")}, + ContextBudgetMaxTokens: 200000, ContextQueryMaterialized: true, + ContinueAfterFinal: &continueAfter, NextModelInputs: &next, + OnStepCommitted: func(_ context.Context, index int, step *sdk.StepResult) error { + indexes = append(indexes, index) + captured = append(captured, step.Messages) + if index == 0 { + next = []sdk.Message{sdk.UserMessage("new direction")} + continueAfter.Store(true) + } + return nil + }, + } + if _, err := native.New(native.Deps{ContextViewApplier: ProviderRunConfigApplier(nil)}).Generate(context.Background(), cfg); err != nil { + t.Fatal(err) + } + if provider.calls.Load() != 2 || len(indexes) != 2 || indexes[0] != 0 || indexes[1] != 1 { + t.Fatalf("calls=%d indexes=%v", provider.calls.Load(), indexes) + } + if len(captured[1]) < 2 || captured[1][0].Role != sdk.MessageRoleUser { + t.Fatalf("steer input missing at persistence barrier: %+v", captured[1]) + } +} diff --git a/internal/db/postgres/sqlc/user_input.sql.go b/internal/db/postgres/sqlc/user_input.sql.go index 6dd613d65..bbcae7eeb 100644 --- a/internal/db/postgres/sqlc/user_input.sql.go +++ b/internal/db/postgres/sqlc/user_input.sql.go @@ -11,6 +11,96 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const cancelPendingUserInputsByRun = `-- name: CancelPendingUserInputsByRun :many +UPDATE user_input_requests +SET status = 'canceled', + result_json = $1, + responded_at = now(), + canceled_at = now(), + updated_at = now() +WHERE team_id = public.memoh_current_team_id() + AND bot_id = $2 + AND session_id = $3 + AND run_id = $4 + AND status = 'pending' + AND runtime_fencing_token IS NOT DISTINCT FROM $5::bigint +RETURNING id, bot_id, session_id, route_id, channel_identity_id, workspace_target_id, tool_call_id, tool_name, short_id, status, runtime_fencing_token, response_control_id, response_payload_hash, input_json, ui_payload_json, interaction_json, interaction_revision, result_json, provider_metadata, requested_by_channel_identity_id, responded_by_channel_identity_id, assistant_message_id, tool_result_message_id, prompt_message_id, prompt_external_message_id, source_platform, reply_target, conversation_type, expires_at, created_at, responded_at, canceled_at, updated_at, team_id, run_id, turn_id +` + +type CancelPendingUserInputsByRunParams struct { + ResultJson []byte `json:"result_json"` + BotID pgtype.UUID `json:"bot_id"` + SessionID pgtype.UUID `json:"session_id"` + RunID pgtype.UUID `json:"run_id"` + RuntimeFencingToken pgtype.Int8 `json:"runtime_fencing_token"` +} + +// A lost run must only invalidate decisions it created. Session-wide +// cancellation would also expire a newer run's ask_user request after a +// stale owner is reaped. +func (q *Queries) CancelPendingUserInputsByRun(ctx context.Context, arg CancelPendingUserInputsByRunParams) ([]UserInputRequest, error) { + rows, err := q.db.Query(ctx, cancelPendingUserInputsByRun, + arg.ResultJson, + arg.BotID, + arg.SessionID, + arg.RunID, + arg.RuntimeFencingToken, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserInputRequest + for rows.Next() { + var i UserInputRequest + if err := rows.Scan( + &i.ID, + &i.BotID, + &i.SessionID, + &i.RouteID, + &i.ChannelIdentityID, + &i.WorkspaceTargetID, + &i.ToolCallID, + &i.ToolName, + &i.ShortID, + &i.Status, + &i.RuntimeFencingToken, + &i.ResponseControlID, + &i.ResponsePayloadHash, + &i.InputJson, + &i.UiPayloadJson, + &i.InteractionJson, + &i.InteractionRevision, + &i.ResultJson, + &i.ProviderMetadata, + &i.RequestedByChannelIdentityID, + &i.RespondedByChannelIdentityID, + &i.AssistantMessageID, + &i.ToolResultMessageID, + &i.PromptMessageID, + &i.PromptExternalMessageID, + &i.SourcePlatform, + &i.ReplyTarget, + &i.ConversationType, + &i.ExpiresAt, + &i.CreatedAt, + &i.RespondedAt, + &i.CanceledAt, + &i.UpdatedAt, + &i.TeamID, + &i.RunID, + &i.TurnID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const cancelPendingUserInputsBySession = `-- name: CancelPendingUserInputsBySession :many UPDATE user_input_requests SET status = 'canceled', @@ -486,6 +576,68 @@ func (q *Queries) FailUserInputRequest(ctx context.Context, arg FailUserInputReq return i, err } +const getInteractiveUserInputRequest = `-- name: GetInteractiveUserInputRequest :one +SELECT id, bot_id, session_id, route_id, channel_identity_id, workspace_target_id, tool_call_id, tool_name, short_id, status, runtime_fencing_token, response_control_id, response_payload_hash, input_json, ui_payload_json, interaction_json, interaction_revision, result_json, provider_metadata, requested_by_channel_identity_id, responded_by_channel_identity_id, assistant_message_id, tool_result_message_id, prompt_message_id, prompt_external_message_id, source_platform, reply_target, conversation_type, expires_at, created_at, responded_at, canceled_at, updated_at, team_id, run_id, turn_id +FROM user_input_requests +WHERE team_id = public.memoh_current_team_id() + AND bot_id = $1 + AND id = $2 + AND status = 'pending' + AND (expires_at IS NULL OR expires_at > now()) +` + +type GetInteractiveUserInputRequestParams struct { + BotID pgtype.UUID `json:"bot_id"` + ID pgtype.UUID `json:"id"` +} + +// Channel-native controls may advance the interaction without carrying the +// runtime fence. The eventual submit remains fence-protected; this lookup +// only admits a live, pending request in the requested bot scope. +func (q *Queries) GetInteractiveUserInputRequest(ctx context.Context, arg GetInteractiveUserInputRequestParams) (UserInputRequest, error) { + row := q.db.QueryRow(ctx, getInteractiveUserInputRequest, arg.BotID, arg.ID) + var i UserInputRequest + err := row.Scan( + &i.ID, + &i.BotID, + &i.SessionID, + &i.RouteID, + &i.ChannelIdentityID, + &i.WorkspaceTargetID, + &i.ToolCallID, + &i.ToolName, + &i.ShortID, + &i.Status, + &i.RuntimeFencingToken, + &i.ResponseControlID, + &i.ResponsePayloadHash, + &i.InputJson, + &i.UiPayloadJson, + &i.InteractionJson, + &i.InteractionRevision, + &i.ResultJson, + &i.ProviderMetadata, + &i.RequestedByChannelIdentityID, + &i.RespondedByChannelIdentityID, + &i.AssistantMessageID, + &i.ToolResultMessageID, + &i.PromptMessageID, + &i.PromptExternalMessageID, + &i.SourcePlatform, + &i.ReplyTarget, + &i.ConversationType, + &i.ExpiresAt, + &i.CreatedAt, + &i.RespondedAt, + &i.CanceledAt, + &i.UpdatedAt, + &i.TeamID, + &i.RunID, + &i.TurnID, + ) + return i, err +} + const getLatestPendingUserInputBySession = `-- name: GetLatestPendingUserInputBySession :one SELECT id, bot_id, session_id, route_id, channel_identity_id, workspace_target_id, tool_call_id, tool_name, short_id, status, runtime_fencing_token, response_control_id, response_payload_hash, input_json, ui_payload_json, interaction_json, interaction_revision, result_json, provider_metadata, requested_by_channel_identity_id, responded_by_channel_identity_id, assistant_message_id, tool_result_message_id, prompt_message_id, prompt_external_message_id, source_platform, reply_target, conversation_type, expires_at, created_at, responded_at, canceled_at, updated_at, team_id, run_id, turn_id FROM user_input_requests diff --git a/internal/db/store/queries.go b/internal/db/store/queries.go index 8ac649999..d54c9a43a 100644 --- a/internal/db/store/queries.go +++ b/internal/db/store/queries.go @@ -36,11 +36,13 @@ type Queries interface { RevokeAgentCredentialByID(ctx context.Context, id pgtype.UUID) (dbsqlc.AgentCredential, error) RevokeAgentCredentialsForBot(ctx context.Context, botID pgtype.UUID) error UpdateAgentCredentialPayloadCAS(ctx context.Context, arg dbsqlc.UpdateAgentCredentialPayloadCASParams) (dbsqlc.AgentCredential, error) + FinalizeSessionRun(ctx context.Context, arg dbsqlc.FinalizeSessionRunParams) (dbsqlc.SessionRun, error) AcquireProviderTemplateSyncLock(ctx context.Context) error ApproveToolApprovalRequest(ctx context.Context, arg dbsqlc.ApproveToolApprovalRequestParams) (dbsqlc.ToolApprovalRequest, error) BumpBotRuntimeConfigEpoch(ctx context.Context, botID pgtype.UUID) (int64, error) CancelPendingToolApprovalsBySession(ctx context.Context, arg dbsqlc.CancelPendingToolApprovalsBySessionParams) ([]dbsqlc.ToolApprovalRequest, error) CancelPendingUserInputsBySession(ctx context.Context, arg dbsqlc.CancelPendingUserInputsBySessionParams) ([]dbsqlc.UserInputRequest, error) + CancelPendingUserInputsByRun(ctx context.Context, arg dbsqlc.CancelPendingUserInputsByRunParams) ([]dbsqlc.UserInputRequest, error) CancelUserInputRequest(ctx context.Context, arg dbsqlc.CancelUserInputRequestParams) (dbsqlc.UserInputRequest, error) ClearBotRuntimeData(ctx context.Context, botID pgtype.UUID) error ClearMCPOAuthTokens(ctx context.Context, connectionID pgtype.UUID) error @@ -263,6 +265,7 @@ type Queries interface { GetToolApprovalRequest(ctx context.Context, id pgtype.UUID) (dbsqlc.ToolApprovalRequest, error) ListPendingToolApprovalsByRun(ctx context.Context, runID pgtype.UUID) ([]dbsqlc.ToolApprovalRequest, error) GetUserInputRequest(ctx context.Context, id pgtype.UUID) (dbsqlc.UserInputRequest, error) + GetInteractiveUserInputRequest(ctx context.Context, arg dbsqlc.GetInteractiveUserInputRequestParams) (dbsqlc.UserInputRequest, error) ListPendingUserInputsByRun(ctx context.Context, runID pgtype.UUID) ([]dbsqlc.UserInputRequest, error) ReclaimWaitingDecisionSessionRun(ctx context.Context, arg dbsqlc.ReclaimWaitingDecisionSessionRunParams) (dbsqlc.SessionRun, error) GetRespondableUserInputRequest(ctx context.Context, arg dbsqlc.GetRespondableUserInputRequestParams) (dbsqlc.UserInputRequest, error) diff --git a/internal/handlers/local_channel.go b/internal/handlers/local_channel.go index d75460b4f..12ba23ad1 100644 --- a/internal/handlers/local_channel.go +++ b/internal/handlers/local_channel.go @@ -506,7 +506,6 @@ func (h *LocalChannelHandler) classifyWebSlash(text string, hasAttachments bool, Surface: surface, IsGroup: false, Directed: true, - SupportsMode: false, KnownCommand: func(resource string) bool { if resource == "help" || resource == "skill" || resource == "permission" { return true @@ -1438,6 +1437,8 @@ type wsRunAdmissionBuilder func(context.Context, sessionruntime.RunHandle) (sess type wsAdmittedTurn struct { TurnID string Position *int64 + Handle sessionruntime.RunHandle + InjectCh chan turn.InjectMessage } // wsSubmission is the canonical form of what a client sent. Its bytes decide @@ -1702,6 +1703,7 @@ func (h *LocalChannelHandler) startWSStream(baseCtx, connCtx context.Context, wr sendWSRunAccepted(writer, ref, admission.Accepted) eventCh := make(chan application.WSStreamEvent, 64) + injectCh := make(chan turn.InjectMessage, 16) forwarded := make(chan struct{}) releaseCompaction := h.agentService.DeferSessionCompaction(botID, ref.SessionID, ref.RunID) go func() { @@ -1711,8 +1713,9 @@ func (h *LocalChannelHandler) startWSStream(baseCtx, connCtx context.Context, wr defer onFinish() } defer close(eventCh) - return runner(streamCtx, ref, wsAdmittedTurn{TurnID: admission.TurnID, Position: admission.TurnPosition}, eventCh, abortCh) + return runner(streamCtx, ref, wsAdmittedTurn{TurnID: admission.TurnID, Position: admission.TurnPosition, Handle: admission.Handle, InjectCh: injectCh}, eventCh, abortCh) }() + close(injectCh) // Every event this run produced has to be published before the run is // declared finished, or a subscriber is shown the terminal state and then // handed output that supposedly preceded it. The forwarder cannot outlive @@ -2330,6 +2333,9 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error { WorkspaceTargetID: workspaceTargetID, ToolHTTPURL: buildACPMCPToolsURL(c, botID), AgentCommand: decision.AgentCommand, + RunHandle: admittedTurn.Handle, + InjectCh: admittedTurn.InjectCh, + QueueSteerEnabled: admittedTurn.InjectCh != nil, } if preparedActivationReq != nil { req.Messages = preparedActivationReq.Messages @@ -2409,6 +2415,8 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error { input.RunID = runRef.RunID input.TurnID = admittedTurn.TurnID input.TurnPosition = admittedTurn.Position + input.RunHandle = admittedTurn.Handle + input.InjectCh = admittedTurn.InjectCh input.OnModelPreferenceSettled = func() { writer.SendJSON(wsOutboundEvent{ Type: "model_preference_settled", @@ -2510,6 +2518,8 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error { input.RunID = runRef.RunID input.TurnID = admittedTurn.TurnID input.TurnPosition = admittedTurn.Position + input.RunHandle = admittedTurn.Handle + input.InjectCh = admittedTurn.InjectCh input.Attachments = editAdmission.preparedAttachments() input.OnModelPreferenceSettled = func() { writer.SendJSON(wsOutboundEvent{ diff --git a/internal/handlers/session_queue.go b/internal/handlers/session_queue.go new file mode 100644 index 000000000..c6a31989a --- /dev/null +++ b/internal/handlers/session_queue.go @@ -0,0 +1,607 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + + "github.com/felinics/memoh/internal/accounts" + "github.com/felinics/memoh/internal/agent/application" + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + "github.com/felinics/memoh/internal/apperror" + "github.com/felinics/memoh/internal/auth" + "github.com/felinics/memoh/internal/bots" + "github.com/felinics/memoh/internal/db" + dbstore "github.com/felinics/memoh/internal/db/store" +) + +// SessionQueueHandler exposes the live steer and follow-up queues. It owns +// authorization and request/response mapping only; transactions and queue +// semantics live in the application service. +type SessionQueueHandler struct { + queries dbstore.Queries + agentService *application.Service + botService *bots.Service + accountService *accounts.Service +} + +func NewSessionQueueHandler(queries dbstore.Queries, agentService *application.Service, botService *bots.Service, accountService *accounts.Service) *SessionQueueHandler { + return &SessionQueueHandler{queries: queries, agentService: agentService, botService: botService, accountService: accountService} +} + +func (h *SessionQueueHandler) Register(e *echo.Echo) { + e.POST("/bots/:bot_id/sessions/:session_id/steer-queue", h.EnqueueSteer) + e.GET("/bots/:bot_id/sessions/:session_id/steer-queue", h.ListSteer) + e.GET("/bots/:bot_id/sessions/:session_id/queue", h.ListSessionQueue) + e.PUT("/bots/:bot_id/sessions/:session_id/steer-queue/reorder", h.ReorderSteer) + e.PATCH("/bots/:bot_id/sessions/:session_id/steer-queue/:item_id", h.UpdateSteer) + e.DELETE("/bots/:bot_id/sessions/:session_id/steer-queue/:item_id", h.CancelSteer) + e.POST("/bots/:bot_id/sessions/:session_id/follow-up-queue", h.EnqueueFollowUp) + e.GET("/bots/:bot_id/sessions/:session_id/follow-up-queue", h.ListFollowUp) + e.PUT("/bots/:bot_id/sessions/:session_id/follow-up-queue/reorder", h.ReorderFollowUp) + e.PATCH("/bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id", h.UpdateFollowUp) + e.DELETE("/bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id", h.CancelFollowUp) + e.POST("/bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id/steer", h.PromoteFollowUpToSteer) +} + +type enqueueQueueRequest struct { + InvocationID string `json:"invocation_id" validate:"required"` + Text string `json:"text" validate:"required"` +} +type updateQueueRequest struct { + Text string `json:"text" validate:"required"` +} +type steerQueueItemResponse struct { + ItemID sessionruntime.SteerItemID `json:"item_id"` + Status sessionruntime.QueueStatus `json:"status"` + Position int64 `json:"position"` + Text string `json:"text"` + TargetRunID string `json:"target_run_id"` +} +type followUpQueueItemResponse struct { + ItemID sessionruntime.FollowUpItemID `json:"item_id"` + Status sessionruntime.QueueStatus `json:"status"` + Position int64 `json:"position"` + Text string `json:"text"` + EnqueuedDuringRunID string `json:"enqueued_during_run_id"` +} +type steerQueueResponse struct { + Items []steerQueueItemResponse `json:"items"` +} +type followUpQueueResponse struct { + Items []followUpQueueItemResponse `json:"items"` +} +type sessionQueueResponse struct { + SteerSupported bool `json:"steer_supported"` + Steer []steerQueueItemResponse `json:"steer"` + FollowUp []followUpQueueItemResponse `json:"follow_up"` +} +type steerQueueReorderRequest struct { + Item sessionruntime.SteerPendingRef `json:"item"` + Before sessionruntime.SteerPendingRef `json:"before"` +} +type followUpQueueReorderRequest struct { + Item sessionruntime.FollowUpPendingRef `json:"item"` + Before sessionruntime.FollowUpPendingRef `json:"before"` +} + +func (h *SessionQueueHandler) authorize(c echo.Context) (string, string, error) { + identityID, err := auth.UserIDFromContext(c) + if err != nil { + return "", "", err + } + botID := strings.TrimSpace(c.Param("bot_id")) + sessionID := strings.TrimSpace(c.Param("session_id")) + if botID == "" || sessionID == "" { + return "", "", apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + sid, err := db.ParseUUID(sessionID) + if err != nil { + return "", "", apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + if h.agentService == nil || h.queries == nil { + return "", "", apperror.New(apperror.CodeQueueAdmissionUnavailable, nil) + } + sess, err := h.queries.GetSessionByID(c.Request().Context(), sid) + if err != nil || sess.BotID.String() != botID { + return "", "", echo.NewHTTPError(http.StatusNotFound, "session not found") + } + // Queue admission is session-scoped. A chat grant is sufficient only for + // sessions owned by that actor; manage access retains the existing ability + // to operate on every session of the bot. This is a hot path (every queue + // panel refresh), so permissions are resolved once from a bot row fetched + // without runtime check summaries instead of through two full authorizations. + if err := h.authorizeQueueAccess(c.Request().Context(), identityID, botID, sess.CreatedByUserID.Valid && sess.CreatedByUserID.String() == identityID); err != nil { + return "", "", err + } + return botID, sessionID, nil +} + +func (h *SessionQueueHandler) authorizeQueueAccess(ctx context.Context, identityID, botID string, ownsSession bool) error { + if h.botService == nil || h.accountService == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "bot services not configured") + } + isAdmin, err := h.accountService.IsAdmin(ctx, identityID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + bot, err := h.botService.GetForAccess(ctx, botID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, bots.ErrBotNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "bot not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + perms, err := h.botService.ResolveUserPermissionsForBot(ctx, bot, identityID, isAdmin) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + switch { + case bots.HasPermission(perms, bots.PermissionManage): + return nil + case bots.HasPermission(perms, bots.PermissionChat): + if !ownsSession { + return echo.NewHTTPError(http.StatusNotFound, "session not found") + } + return nil + default: + return echo.NewHTTPError(http.StatusForbidden, "bot access denied") + } +} + +func decodeQueueRequest(c echo.Context) (enqueueQueueRequest, error) { + var req enqueueQueueRequest + if err := c.Bind(&req); err != nil { + return req, apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + req.InvocationID = strings.TrimSpace(req.InvocationID) + if req.InvocationID == "" || strings.TrimSpace(req.Text) == "" { + return req, apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + return req, nil +} + +func marshalQueuePayload(text string) ([]byte, error) { + return json.Marshal(map[string]string{"text": strings.TrimSpace(text)}) +} + +func queueAdmissionError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, sessionruntime.ErrQueueSteerUnsupported): + return apperror.New(apperror.CodeQueueSteerUnsupported, nil) + case errors.Is(err, sessionruntime.ErrQueueNoActiveRun): + return apperror.New(apperror.CodeQueueNoActiveRun, nil) + case errors.Is(err, sessionruntime.ErrQueueInvocationConflict): + return apperror.New(apperror.CodeSessionInvocationConflict, nil) + case errors.Is(err, sessionruntime.ErrQueueAdmissionOverloaded): + return apperror.New(apperror.CodeQueueAdmissionOverloaded, nil) + case errors.Is(err, sessionruntime.ErrQueueCapacityExceeded): + return apperror.New(apperror.CodeQueueCapacityExceeded, nil) + case errors.Is(err, sessionruntime.ErrQueueInvalidReference): + return apperror.New(apperror.CodeQueueRequestInvalid, nil) + default: + return err + } +} + +func queueMutationError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, sessionruntime.ErrQueueSteerUnsupported): + return apperror.New(apperror.CodeQueueSteerUnsupported, nil) + case errors.Is(err, sessionruntime.ErrQueueNoActiveRun): + return apperror.New(apperror.CodeQueueNoActiveRun, nil) + case errors.Is(err, sessionruntime.ErrQueueNotPending), errors.Is(err, sessionruntime.ErrQueueInvalidReference): + return apperror.New(apperror.CodeQueueItemNotPending, nil) + case errors.Is(err, sessionruntime.ErrQueueCapacityExceeded): + return apperror.New(apperror.CodeQueueCapacityExceeded, nil) + default: + return err + } +} + +func decodeUpdateQueueRequest(c echo.Context) (updateQueueRequest, error) { + var req updateQueueRequest + if err := c.Bind(&req); err != nil || strings.TrimSpace(req.Text) == "" { + return req, apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + return req, nil +} + +func queueItemIDParam(c echo.Context) (string, error) { + id := strings.TrimSpace(c.Param("item_id")) + if _, err := uuid.Parse(id); err != nil { + return "", apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + return id, nil +} + +func decodeSteerReorderRequest(c echo.Context) (steerQueueReorderRequest, error) { + var req steerQueueReorderRequest + if err := c.Bind(&req); err != nil { + return req, apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + if err := validateReorderRefs(string(req.Item.ItemID), string(req.Before.ItemID)); err != nil { + return req, err + } + return req, nil +} + +func decodeFollowUpReorderRequest(c echo.Context) (followUpQueueReorderRequest, error) { + var req followUpQueueReorderRequest + if err := c.Bind(&req); err != nil { + return req, apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + if err := validateReorderRefs(string(req.Item.ItemID), string(req.Before.ItemID)); err != nil { + return req, err + } + return req, nil +} + +func validateReorderRefs(item, before string) error { + if _, err := uuid.Parse(item); err != nil { + return apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + if before != "" { + if _, err := uuid.Parse(before); err != nil { + return apperror.New(apperror.CodeQueueRequestInvalid, nil) + } + } + return nil +} + +// EnqueueSteer godoc +// @Summary Enqueue steer input for the active session run +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param body body enqueueQueueRequest true "Steer payload" +// @Success 202 {object} steerQueueItemResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/steer-queue [post]. +func (h *SessionQueueHandler) EnqueueSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + req, err := decodeQueueRequest(c) + if err != nil { + return err + } + payload, err := marshalQueuePayload(req.Text) + if err != nil { + return err + } + item, err := h.agentService.EnqueueSteer(c.Request().Context(), botID, sid, req.InvocationID, payload) + if err = queueAdmissionError(err); err != nil { + return err + } + return c.JSON(http.StatusAccepted, steerQueueItemResponseFrom(item)) +} + +// EnqueueFollowUp godoc +// @Summary Enqueue follow-up input for the active session run +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param body body enqueueQueueRequest true "Follow-up payload" +// @Success 202 {object} followUpQueueItemResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue [post]. +func (h *SessionQueueHandler) EnqueueFollowUp(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + req, err := decodeQueueRequest(c) + if err != nil { + return err + } + payload, err := marshalQueuePayload(req.Text) + if err != nil { + return err + } + item, err := h.agentService.EnqueueFollowUp(c.Request().Context(), botID, sid, req.InvocationID, payload) + if err = queueAdmissionError(err); err != nil { + return err + } + return c.JSON(http.StatusAccepted, followUpQueueItemResponseFrom(item)) +} + +// ListSteer godoc +// @Summary List pending steer inputs +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Success 200 {object} steerQueueResponse +// @Failure 403 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/steer-queue [get]. +func (h *SessionQueueHandler) ListSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + queues, err := h.agentService.ListSessionQueues(c.Request().Context(), botID, sid) + if err != nil { + return err + } + return c.JSON(http.StatusOK, steerQueueResponse{Items: mapQueueItems(queues.Steer, steerQueueItemResponseFrom)}) +} + +// ListFollowUp godoc +// @Summary List pending follow-up inputs +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Success 200 {object} followUpQueueResponse +// @Failure 403 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue [get]. +func (h *SessionQueueHandler) ListFollowUp(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + queues, err := h.agentService.ListSessionQueues(c.Request().Context(), botID, sid) + if err != nil { + return err + } + return c.JSON(http.StatusOK, followUpQueueResponse{Items: mapQueueItems(queues.FollowUp, followUpQueueItemResponseFrom)}) +} + +// ListSessionQueue godoc +// @Summary List pending steer and follow-up inputs in one response +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Success 200 {object} sessionQueueResponse +// @Failure 403 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/queue [get]. +func (h *SessionQueueHandler) ListSessionQueue(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + queues, err := h.agentService.ListSessionQueues(c.Request().Context(), botID, sid) + if err != nil { + return err + } + return c.JSON(http.StatusOK, sessionQueueResponse{ + SteerSupported: queues.SteerSupported, + Steer: mapQueueItems(queues.Steer, steerQueueItemResponseFrom), + FollowUp: mapQueueItems(queues.FollowUp, followUpQueueItemResponseFrom), + }) +} + +// ReorderSteer godoc +// @Summary Reorder accepted steer inputs +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param body body steerQueueReorderRequest true "Typed steer queue references" +// @Success 200 {object} steerQueueResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/steer-queue/reorder [put]. +func (h *SessionQueueHandler) ReorderSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + req, err := decodeSteerReorderRequest(c) + if err != nil { + return err + } + items, err := h.agentService.ReorderSteer(c.Request().Context(), botID, sid, req.Item, req.Before) + if err = queueMutationError(err); err != nil { + return err + } + return c.JSON(http.StatusOK, steerQueueResponse{Items: mapQueueItems(items, steerQueueItemResponseFrom)}) +} + +// ReorderFollowUp godoc +// @Summary Reorder accepted follow-up inputs +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param body body followUpQueueReorderRequest true "Typed follow-up queue references" +// @Success 200 {object} followUpQueueResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder [put]. +func (h *SessionQueueHandler) ReorderFollowUp(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + req, err := decodeFollowUpReorderRequest(c) + if err != nil { + return err + } + items, err := h.agentService.ReorderFollowUp(c.Request().Context(), botID, sid, req.Item, req.Before) + if err = queueMutationError(err); err != nil { + return err + } + return c.JSON(http.StatusOK, followUpQueueResponse{Items: mapQueueItems(items, followUpQueueItemResponseFrom)}) +} + +// UpdateSteer godoc +// @Summary Edit an accepted steer input +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param item_id path string true "Queue item ID" +// @Param body body updateQueueRequest true "Updated steer payload" +// @Success 200 {object} steerQueueItemResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id} [patch]. +func (h *SessionQueueHandler) UpdateSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + itemID, err := queueItemIDParam(c) + if err != nil { + return err + } + req, err := decodeUpdateQueueRequest(c) + if err != nil { + return err + } + payload, err := marshalQueuePayload(req.Text) + if err != nil { + return err + } + item, err := h.agentService.UpdateSteer(c.Request().Context(), botID, sid, itemID, payload) + if err = queueMutationError(err); err != nil { + return err + } + return c.JSON(http.StatusOK, steerQueueItemResponseFrom(item)) +} + +// CancelSteer godoc +// @Summary Cancel an accepted steer input +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param item_id path string true "Queue item ID" +// @Success 204 +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id} [delete]. +func (h *SessionQueueHandler) CancelSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + itemID, err := queueItemIDParam(c) + if err != nil { + return err + } + if err = queueMutationError(h.agentService.CancelSteer(c.Request().Context(), botID, sid, itemID)); err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} + +// UpdateFollowUp godoc +// @Summary Edit an accepted follow-up input +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param item_id path string true "Queue item ID" +// @Param body body updateQueueRequest true "Updated follow-up payload" +// @Success 200 {object} followUpQueueItemResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id} [patch]. +func (h *SessionQueueHandler) UpdateFollowUp(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + itemID, err := queueItemIDParam(c) + if err != nil { + return err + } + req, err := decodeUpdateQueueRequest(c) + if err != nil { + return err + } + payload, err := marshalQueuePayload(req.Text) + if err != nil { + return err + } + item, err := h.agentService.UpdateFollowUp(c.Request().Context(), botID, sid, itemID, payload) + if err = queueMutationError(err); err != nil { + return err + } + return c.JSON(http.StatusOK, followUpQueueItemResponseFrom(item)) +} + +// CancelFollowUp godoc +// @Summary Cancel an accepted follow-up input +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param item_id path string true "Queue item ID" +// @Success 204 +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id} [delete]. +func (h *SessionQueueHandler) CancelFollowUp(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + itemID, err := queueItemIDParam(c) + if err != nil { + return err + } + if err = queueMutationError(h.agentService.CancelFollowUp(c.Request().Context(), botID, sid, itemID)); err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} + +// PromoteFollowUpToSteer godoc +// @Summary Promote an accepted follow-up input to steer the active run +// @Tags sessions +// @Param bot_id path string true "Bot ID" +// @Param session_id path string true "Session ID" +// @Param item_id path string true "Follow-up queue item ID" +// @Success 202 {object} steerQueueItemResponse +// @Failure 400 {object} apperror.Problem +// @Failure 403 {object} apperror.Problem +// @Failure 409 {object} apperror.Problem +// @Router /bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer [post]. +func (h *SessionQueueHandler) PromoteFollowUpToSteer(c echo.Context) error { + botID, sid, err := h.authorize(c) + if err != nil { + return err + } + itemID, err := queueItemIDParam(c) + if err != nil { + return err + } + result, err := h.agentService.PromoteFollowUpToSteer(c.Request().Context(), botID, sid, sessionruntime.FollowUpPendingRef{ItemID: sessionruntime.FollowUpItemID(itemID)}) + if err = queueMutationError(err); err != nil { + return err + } + return c.JSON(http.StatusAccepted, steerQueueItemResponseFrom(result.Steer)) +} + +func steerQueueItemResponseFrom(item sessionruntime.SteerItem) steerQueueItemResponse { + return steerQueueItemResponse{ItemID: item.ID, Status: item.Status, Position: item.Position, Text: application.QueuePayloadText(item.Payload), TargetRunID: item.TargetRunID} +} + +func followUpQueueItemResponseFrom(item sessionruntime.FollowUpItem) followUpQueueItemResponse { + return followUpQueueItemResponse{ItemID: item.ID, Status: item.Status, Position: item.Position, Text: application.QueuePayloadText(item.Payload), EnqueuedDuringRunID: item.EnqueuedDuringRunID} +} + +func mapQueueItems[T any, R any](items []T, mapItem func(T) R) []R { + out := make([]R, 0, len(items)) + for _, item := range items { + out = append(out, mapItem(item)) + } + return out +} diff --git a/internal/handlers/session_queue_test.go b/internal/handlers/session_queue_test.go new file mode 100644 index 000000000..127361908 --- /dev/null +++ b/internal/handlers/session_queue_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" +) + +func TestSessionQueueHandlerRegistersSeparateQueueRoutes(t *testing.T) { + e := echo.New() + (&SessionQueueHandler{}).Register(e) + want := map[string]bool{ + http.MethodPost + " /bots/:bot_id/sessions/:session_id/steer-queue": true, + http.MethodGet + " /bots/:bot_id/sessions/:session_id/steer-queue": true, + http.MethodGet + " /bots/:bot_id/sessions/:session_id/queue": true, + http.MethodPut + " /bots/:bot_id/sessions/:session_id/steer-queue/reorder": true, + http.MethodPatch + " /bots/:bot_id/sessions/:session_id/steer-queue/:item_id": true, + http.MethodDelete + " /bots/:bot_id/sessions/:session_id/steer-queue/:item_id": true, + http.MethodPost + " /bots/:bot_id/sessions/:session_id/follow-up-queue": true, + http.MethodGet + " /bots/:bot_id/sessions/:session_id/follow-up-queue": true, + http.MethodPut + " /bots/:bot_id/sessions/:session_id/follow-up-queue/reorder": true, + http.MethodPatch + " /bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id": true, + http.MethodDelete + " /bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id": true, + http.MethodPost + " /bots/:bot_id/sessions/:session_id/follow-up-queue/:item_id/steer": true, + } + for _, route := range e.Routes() { + delete(want, route.Method+" "+route.Path) + } + if len(want) != 0 { + t.Fatalf("missing queue routes: %v", want) + } +} + +func TestSessionQueueReorderRequestsDecodeTypedReferences(t *testing.T) { + itemID := "4ed490e0-649a-41d5-8456-6fe2ebf1e031" + beforeID := "01a9b524-fbe0-4cb0-b42a-a4fe2c284e26" + body := []byte(`{"item":{"item_id":"` + itemID + `"},"before":{"item_id":"` + beforeID + `"}}`) + + e := echo.New() + steerContext := e.NewContext(httptest.NewRequest(http.MethodPut, "/", bytes.NewReader(body)), httptest.NewRecorder()) + steerContext.Request().Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + steer, err := decodeSteerReorderRequest(steerContext) + if err != nil || steer.Item.ItemID != sessionruntime.SteerItemID(itemID) || steer.Before.ItemID != sessionruntime.SteerItemID(beforeID) { + t.Fatalf("steer reorder request = %#v, %v", steer, err) + } + + followContext := e.NewContext(httptest.NewRequest(http.MethodPut, "/", bytes.NewReader(body)), httptest.NewRecorder()) + followContext.Request().Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + follow, err := decodeFollowUpReorderRequest(followContext) + if err != nil || follow.Item.ItemID != sessionruntime.FollowUpItemID(itemID) || follow.Before.ItemID != sessionruntime.FollowUpItemID(beforeID) { + t.Fatalf("follow-up reorder request = %#v, %v", follow, err) + } +} + +func TestSessionQueueResponsesDoNotUseMixedQueueKind(t *testing.T) { + steerJSON, err := json.Marshal(steerQueueItemResponseFrom(sessionruntime.SteerItem{ID: "steer", Status: sessionruntime.QueueAccepted, Position: 1, Payload: []byte(`{"text":"s"}`), TargetRunID: "run-0"})) + if err != nil { + t.Fatal(err) + } + followJSON, err := json.Marshal(followUpQueueItemResponseFrom(sessionruntime.FollowUpItem{ID: "follow", Status: sessionruntime.QueueAccepted, Position: 2, Payload: []byte(`{"text":"f"}`), EnqueuedDuringRunID: "run-0"})) + if err != nil { + t.Fatal(err) + } + for name, payload := range map[string][]byte{"steer": steerJSON, "follow_up": followJSON} { + var response map[string]any + if err := json.Unmarshal(payload, &response); err != nil { + t.Fatal(err) + } + if _, ok := response["queue"]; ok { + t.Fatalf("%s response contains mixed queue discriminator: %s", name, payload) + } + if _, ok := response["kind"]; ok { + t.Fatalf("%s response contains mixed kind discriminator: %s", name, payload) + } + } +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index cd214d346..7d5ae6c29 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -527,6 +527,18 @@ "generic": "Slash command failed." } }, + "queue": { + "steerAccepted": "Steer queued.", + "accepted": "Queued.", + "noActiveRun": "There is no active response to receive this input.", + "overloaded": "Queue admission is busy. Try again shortly.", + "unavailable": "Queue admission is unavailable. Try again shortly.", + "conflict": "This message was already submitted with different content.", + "invalid": "Queue input must include text.", + "unsupported": "Queue controls are not available in discussion sessions.", + "capacity": "The queue for this session is full. Wait for pending items to run before adding more.", + "followUpUnsupportedChannel": "Queued follow-ups are not available on this channel; its replies are delivered from the message you send. Use /steer to add to the current reply, or queue from the web app." + }, "menu": { "start": "Show the welcome message", "help": "Show available commands", @@ -547,6 +559,8 @@ "skill": "View bot skills", "fs": "Browse workspace files", "access": "Inspect identity and permissions", - "compact": "Compact conversation context" + "compact": "Compact conversation context", + "queue": "Queue input", + "steer": "Steer the current response" } } diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 6c0b9362a..b83a04424 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -527,6 +527,18 @@ "generic": "slash コマンドに失敗しました。" } }, + "queue": { + "steerAccepted": "Steer をキューに追加しました。", + "accepted": "キューに追加しました。", + "noActiveRun": "この入力を受け取るアクティブな応答はありません。", + "overloaded": "キュー処理が混み合っています。少し待ってから再試行してください。", + "unavailable": "キュー処理を利用できません。少し待ってから再試行してください。", + "conflict": "このメッセージは別の内容ですでに送信されています。", + "invalid": "キュー入力にはテキストが必要です。", + "unsupported": "ディスカッションセッションではキュー操作を利用できません。", + "capacity": "このセッションのキューは上限に達しています。保留中の項目が実行されるまでお待ちください。", + "followUpUnsupportedChannel": "このチャンネルではキュー投入を利用できません。返信は送信したメッセージに対してのみ届きます。/steer で現在の返信に追加するか、Web アプリからキューに追加してください。" + }, "menu": { "start": "ウェルカムメッセージを表示する", "help": "使用可能なコマンドを表示する", @@ -547,6 +559,8 @@ "skill": "BotのSkillを表示する", "fs": "Workspace のファイルを参照する", "access": "ID と権限を検査する", - "compact": "コンパクトな会話コンテキスト" + "compact": "コンパクトな会話コンテキスト", + "queue": "入力をキューに追加", + "steer": "現在の応答を Steer" } } diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index fbbf278f9..721303858 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -527,6 +527,18 @@ "generic": "slash 命令执行失败。" } }, + "queue": { + "steerAccepted": "已加入 steer 队列。", + "accepted": "已加入队列。", + "noActiveRun": "当前没有可接收此输入的回复。", + "overloaded": "队列正忙,请稍后重试。", + "unavailable": "队列暂不可用,请稍后重试。", + "conflict": "这条消息已用不同内容提交过。", + "invalid": "队列输入需要包含文本。", + "unsupported": "讨论会话暂不支持队列操作。", + "capacity": "该会话的队列已满,请等待待处理项执行后再添加。", + "followUpUnsupportedChannel": "该渠道暂不支持排队消息:渠道回复只能随你发送的消息投递。可用 /steer 补充当前回复,或在 Web 端排队。" + }, "menu": { "start": "显示欢迎信息", "help": "查看可用命令", @@ -547,6 +559,8 @@ "skill": "查看技能", "fs": "浏览工作区文件", "access": "查看身份与权限", - "compact": "压缩对话上下文" + "compact": "压缩对话上下文", + "queue": "加入队列", + "steer": "引导当前回复" } } diff --git a/internal/rpc/serverruntime/serverruntime.go b/internal/rpc/serverruntime/serverruntime.go index f50b6f7d0..4e4d878ed 100644 --- a/internal/rpc/serverruntime/serverruntime.go +++ b/internal/rpc/serverruntime/serverruntime.go @@ -25,6 +25,8 @@ const ( MethodCommandHasResource = "server.command.has_resource" MethodCommandMemberRole = "server.command.member_role" MethodCommandResolveLocale = "server.command.resolve_locale" + MethodQueueEnqueueSteer = "server.queue.enqueue_steer" + MethodQueueEnqueueFollowUp = "server.queue.enqueue_follow_up" MethodResolveSkills = "server.skills.resolve" MethodSynthesize = "server.audio.synthesize" MethodTranscribe = "server.audio.transcribe" @@ -78,6 +80,29 @@ func (c *Client) ResolveLocale(ctx context.Context, botID string) string { return out } +func (c *Client) EnqueueSteer(ctx context.Context, input inbound.QueueCommandInput) error { + return c.queueCall(ctx, MethodQueueEnqueueSteer, input) +} + +func (c *Client) EnqueueFollowUp(ctx context.Context, input inbound.QueueCommandInput) error { + return c.queueCall(ctx, MethodQueueEnqueueFollowUp, input) +} + +func (c *Client) queueCall(ctx context.Context, method string, input inbound.QueueCommandInput) error { + err := c.call(ctx, method, input, nil) + if code := queueCommandCode(err); code != "" { + return inbound.NewQueueCommandError(code) + } + return err +} + +func queueCommandCode(err error) string { + if err == nil { + return "" + } + return inbound.NormalizeQueueCommandCode(err.Error()) +} + func (c *Client) ResolveTextRequestedSkills(ctx context.Context, botID string, names []string) ([]skills.ResolvedSkill, error) { var out []skills.ResolvedSkill in := struct { @@ -136,7 +161,7 @@ type transcriptionResult struct { func (r transcriptionResult) GetText() string { return r.Text } -func Handlers(commandHandler *command.Handler, skillHandler *handlers.ContainerdHandler, audioService *audio.Service) map[string]runtimeRpc.Handler { +func Handlers(commandHandler *command.Handler, queueHandler inbound.QueueCommandHandler, skillHandler *handlers.ContainerdHandler, audioService *audio.Service) map[string]runtimeRpc.Handler { decode := func(raw json.RawMessage, dst any) error { return json.Unmarshal(raw, dst) } return map[string]runtimeRpc.Handler{ MethodCommandAccess: func(ctx context.Context, raw json.RawMessage) (any, error) { @@ -188,6 +213,8 @@ func Handlers(commandHandler *command.Handler, skillHandler *handlers.Containerd } return commandHandler.ResolveLocale(ctx, botID), nil }, + MethodQueueEnqueueSteer: queueHandlerFunc(decode, queueHandler.EnqueueSteer), + MethodQueueEnqueueFollowUp: queueHandlerFunc(decode, queueHandler.EnqueueFollowUp), MethodResolveSkills: func(ctx context.Context, raw json.RawMessage) (any, error) { var in struct { BotID string @@ -231,6 +258,20 @@ func Handlers(commandHandler *command.Handler, skillHandler *handlers.Containerd } } +func queueHandlerFunc(decode func(json.RawMessage, any) error, handler func(context.Context, inbound.QueueCommandInput) error) runtimeRpc.Handler { + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var input inbound.QueueCommandInput + if err := decode(raw, &input); err != nil { + return nil, err + } + err := handler(ctx, input) + if code := inbound.QueueCommandErrorCode(err); code != "" { + return nil, runtimeRpc.Public(inbound.NewQueueCommandError(code)) + } + return nil, err + } +} + func transcriptionResultFromSDK(result *sdk.TranscriptionResult) transcriptionResult { if result == nil { return transcriptionResult{} @@ -240,5 +281,6 @@ func transcriptionResultFromSDK(result *sdk.TranscriptionResult) transcriptionRe var ( _ inbound.CommandHandler = (*Client)(nil) + _ inbound.QueueCommandHandler = (*Client)(nil) _ inbound.RequestedSkillResolver = (*Client)(nil) ) diff --git a/internal/rpc/serverruntime/serverruntime_queue_test.go b/internal/rpc/serverruntime/serverruntime_queue_test.go new file mode 100644 index 000000000..f1ed014a0 --- /dev/null +++ b/internal/rpc/serverruntime/serverruntime_queue_test.go @@ -0,0 +1,74 @@ +package serverruntime + +import ( + "context" + "encoding/json" + "testing" + + "github.com/felinics/memoh/internal/channel/inbound" +) + +type queueHandlerStub struct { + steer []inbound.QueueCommandInput + followUp []inbound.QueueCommandInput + err error +} + +func (s *queueHandlerStub) EnqueueSteer(_ context.Context, input inbound.QueueCommandInput) error { + s.steer = append(s.steer, input) + return s.err +} + +func (s *queueHandlerStub) EnqueueFollowUp(_ context.Context, input inbound.QueueCommandInput) error { + s.followUp = append(s.followUp, input) + return s.err +} + +func TestQueueRPCHandlersKeepQueueOperationsSeparate(t *testing.T) { + stub := &queueHandlerStub{} + handlers := Handlers(nil, stub, nil, nil) + want := inbound.QueueCommandInput{ + BotID: "bot-1", SessionID: "session-1", InvocationID: "channel:42:queue:steer", Text: "use bun", + } + payload, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + + if _, err := handlers[MethodQueueEnqueueSteer](context.Background(), payload); err != nil { + t.Fatalf("steer handler error = %v", err) + } + if _, err := handlers[MethodQueueEnqueueFollowUp](context.Background(), payload); err != nil { + t.Fatalf("follow-up handler error = %v", err) + } + if len(stub.steer) != 1 || len(stub.followUp) != 1 { + t.Fatalf("calls = steer %#v, follow-up %#v", stub.steer, stub.followUp) + } + if stub.steer[0] != want || stub.followUp[0] != want { + t.Fatalf("RPC changed queue input: steer %#v, follow-up %#v, want %#v", stub.steer[0], stub.followUp[0], want) + } +} + +func TestQueueRPCHandlerPublishesOnlyStableQueueCode(t *testing.T) { + stub := &queueHandlerStub{err: inbound.NewQueueCommandError(inbound.QueueCommandCodeNoActiveRun)} + handlers := Handlers(nil, stub, nil, nil) + payload := json.RawMessage(`{"bot_id":"bot-1","session_id":"session-1","invocation_id":"channel:42:queue:steer","text":"use bun"}`) + + _, err := handlers[MethodQueueEnqueueSteer](context.Background(), payload) + if err == nil || err.Error() != inbound.QueueCommandCodeNoActiveRun { + t.Fatalf("handler error = %v, want stable queue code", err) + } +} + +func TestQueueCommandCodeAcceptsOnlyStableRPCVocabulary(t *testing.T) { + if got := queueCommandCode(inbound.NewQueueCommandError(inbound.QueueCommandCodeConflict)); got != inbound.QueueCommandCodeConflict { + t.Fatalf("stable code = %q", got) + } + if got := queueCommandCode(assertionError("database diagnostic")); got != "" { + t.Fatalf("unsafe error became user-visible code %q", got) + } +} + +type assertionError string + +func (e assertionError) Error() string { return string(e) } diff --git a/internal/slash/classifier.go b/internal/slash/classifier.go index 33fa22e41..aa40742e4 100644 --- a/internal/slash/classifier.go +++ b/internal/slash/classifier.go @@ -57,7 +57,6 @@ type ClassifyInput struct { Surface Surface IsGroup bool Directed bool - SupportsMode bool BotAliases []string KnownCommand func(resource string) bool WebActionSupported func(resource, action string) bool @@ -90,12 +89,8 @@ func Classify(input ClassifyInput) Decision { return Decision{Kind: DecisionReject, Code: CodeInvalidSkillSlashSyntax, Directed: effectiveDirected, Invocation: &invocation} } - if input.Surface == SurfaceChannel && input.SupportsMode && isModePrefix(parsed.Resource) { - remainder := strings.TrimSpace(invocation.Rest) - if strings.HasPrefix(remainder, "/") || isSlashLike(remainder, input.BotAliases) { - return Decision{Kind: DecisionReject, Code: CodeUnknownSlash, Directed: effectiveDirected, Invocation: &invocation} - } - return Decision{Kind: DecisionNormalChat, Directed: effectiveDirected, Invocation: &invocation} + if input.Surface == SurfaceChannel && isRemovedModeCommand(parsed.Resource) { + return Decision{Kind: DecisionReject, Code: CodeUnknownSlash, Directed: effectiveDirected, Invocation: &invocation} } if isCommandName(invocation.Selector) && isKnown(input.KnownCommand, parsed.Resource) { @@ -143,18 +138,13 @@ func Classify(input ClassifyInput) Decision { return Decision{Kind: DecisionNormalChat, Directed: effectiveDirected, Invocation: &invocation} } -func isSlashLike(text string, aliases []string) bool { - _, err := commandsyntax.ParseInvocation(commandsyntax.InvocationInput{Text: text, BotAliases: aliases}) - return err == nil -} - func isKnown(fn func(string) bool, resource string) bool { return fn != nil && fn(resource) } -func isModePrefix(resource string) bool { +func isRemovedModeCommand(resource string) bool { switch resource { - case "now", "btw", "next": + case "now", "btw", "next", "followup": return true default: return false diff --git a/internal/slash/classifier_test.go b/internal/slash/classifier_test.go index 0c5a37682..2f24ae1d4 100644 --- a/internal/slash/classifier_test.go +++ b/internal/slash/classifier_test.go @@ -222,16 +222,59 @@ func TestClassifyWebKnownCommands(t *testing.T) { func TestClassifyModeSlashRemainderRejects(t *testing.T) { decision := Classify(ClassifyInput{ - Text: "/btw /help", - Surface: SurfaceChannel, - Directed: true, - SupportsMode: true, + Text: "/btw /help", + Surface: SurfaceChannel, + Directed: true, }) if decision.Kind != DecisionReject || decision.Code != CodeUnknownSlash { t.Fatalf("decision = %#v, want slash reject", decision) } } +func TestClassifyChannelQueueControls(t *testing.T) { + known := func(resource string) bool { + return resource == "queue" || resource == "steer" + } + for _, text := range []string{"/queue after this", "/steer change direction"} { + t.Run(text, func(t *testing.T) { + decision := Classify(ClassifyInput{ + Text: text, + Surface: SurfaceChannel, + IsGroup: true, + Directed: true, + KnownCommand: known, + }) + if decision.Kind != DecisionCommandAction || decision.Invocation == nil { + t.Fatalf("decision = %#v, want queue command action", decision) + } + if decision.Invocation.Rest == "" { + t.Fatalf("invocation = %#v, want command text", decision.Invocation) + } + }) + } + + undirected := Classify(ClassifyInput{ + Text: "/queue after this", + Surface: SurfaceChannel, + IsGroup: true, + KnownCommand: known, + }) + if undirected.Kind != DecisionRejectNoop { + t.Fatalf("undirected decision = %#v, want reject noop", undirected) + } + + removed := Classify(ClassifyInput{ + Text: "/followup continue later", + Surface: SurfaceChannel, + IsGroup: true, + Directed: true, + KnownCommand: known, + }) + if removed.Kind != DecisionReject || removed.Code != CodeUnknownSlash { + t.Fatalf("removed command decision = %#v, want unknown slash reject", removed) + } +} + // TestClassifySlashProseFallsThroughToChat pins the path/URL/prose carve-out: // a head token outside every control grammar (paths, URLs, punctuation, // non-ASCII) is prose that happens to start with a slash and must reach the @@ -273,18 +316,15 @@ func TestClassifyKnownCommandIgnoresAttachments(t *testing.T) { } } -// TestClassifyModePrefixWithAttachmentsStaysNormalChat: "/now" + photo is a -// normal chat message in now-mode, not a rejected control message. -func TestClassifyModePrefixWithAttachmentsStaysNormalChat(t *testing.T) { +func TestClassifyRemovedModePrefixWithAttachmentsIsUnknown(t *testing.T) { decision := Classify(ClassifyInput{ Text: "/now look at this", Surface: SurfaceChannel, Directed: true, - SupportsMode: true, HasAttachments: true, }) - if decision.Kind != DecisionNormalChat { - t.Fatalf("decision = %#v, want normal chat for mode prefix with attachments", decision) + if decision.Kind != DecisionReject || decision.Code != CodeUnknownSlash { + t.Fatalf("decision = %#v, want unknown slash", decision) } } diff --git a/internal/testutil/sessionledger/ledger.go b/internal/testutil/sessionledger/ledger.go new file mode 100644 index 000000000..7e7827c26 --- /dev/null +++ b/internal/testutil/sessionledger/ledger.go @@ -0,0 +1,374 @@ +// Package sessionledger supplies a shared in-memory ledger for runtime and +// application tests. No production composition root uses this fixture. +package sessionledger + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/felinics/memoh/internal/agent/runtime/session/ledger" +) + +// Store is an in-memory ledger with the same guarantees the PostgreSQL +// adapter provides: one active run per session, fenced idempotent transitions, +// and a monotonic token sequence. It exists so admission ordering can be tested +// without a database; the adapter's own SQL is covered by its integration test. +type Store struct { + Mu sync.Mutex + Runs map[string]*ledger.Run + // bySession preserves insertion order so ActiveRun is deterministic. + Order []string + Token int64 + + AdmitErr error + ClaimErr error + TokenErr error + PrepareErr error + FinalizeErr error + ClaimHook func(runID string) + + Admits int + Claims int + Finalized []ledger.FinalizeParams +} + +func New() *Store { + return &Store{Runs: map[string]*ledger.Run{}} +} + +func (f *Store) Admit(_ context.Context, params ledger.AdmitParams) (ledger.Run, bool, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + f.Admits++ + if f.AdmitErr != nil { + return ledger.Run{}, false, f.AdmitErr + } + for _, id := range f.Order { + run := f.Runs[id] + if run.SessionID != params.SessionID { + continue + } + if run.InvocationID == params.InvocationID { + return *run, false, nil + } + if run.State.Active() { + return ledger.Run{}, false, ledger.ErrSessionBusy + } + } + position := int64(1) + for _, id := range f.Order { + if f.Runs[id].SessionID == params.SessionID { + position++ + } + } + run := &ledger.Run{ + RunID: params.RunID, + BotID: params.BotID, + SessionID: params.SessionID, + InvocationID: params.InvocationID, + TurnID: params.TurnID, + TurnPosition: position, + State: ledger.StateAccepted, + Input: params.Input, + InputFingerprint: params.InputFingerprint, + CreatedAt: time.Now(), + } + f.Runs[run.RunID] = run + f.Order = append(f.Order, run.RunID) + return *run, true, nil +} + +func (f *Store) Get(_ context.Context, runID string) (ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + run, ok := f.Runs[runID] + if !ok { + return ledger.Run{}, ledger.ErrRunNotFound + } + return *run, nil +} + +func (f *Store) GetByInvocation(_ context.Context, sessionID, invocationID string) (ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + for _, id := range f.Order { + if run := f.Runs[id]; run.SessionID == sessionID && run.InvocationID == invocationID { + return *run, nil + } + } + return ledger.Run{}, ledger.ErrRunNotFound +} + +func (f *Store) ActiveRun(_ context.Context, sessionID string) (ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + for _, id := range f.Order { + if run := f.Runs[id]; run.SessionID == sessionID && run.State.Active() { + return *run, nil + } + } + return ledger.Run{}, ledger.ErrRunNotFound +} + +func (f *Store) LatestRun(_ context.Context, sessionID string) (ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + for i := len(f.Order) - 1; i >= 0; i-- { + if run := f.Runs[f.Order[i]]; run.SessionID == sessionID { + return *run, nil + } + } + return ledger.Run{}, ledger.ErrRunNotFound +} + +func (f *Store) NextFencingToken(context.Context) (int64, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + if f.TokenErr != nil { + return 0, f.TokenErr + } + f.Token++ + return f.Token, nil +} + +func (f *Store) Claim(_ context.Context, params ledger.ClaimParams) (ledger.Run, bool, error) { + if f.ClaimHook != nil { + f.ClaimHook(params.RunID) + } + f.Mu.Lock() + defer f.Mu.Unlock() + f.Claims++ + if f.ClaimErr != nil { + return ledger.Run{}, false, f.ClaimErr + } + run, ok := f.Runs[params.RunID] + if !ok { + return ledger.Run{}, false, ledger.ErrRunNotFound + } + if run.State != ledger.StateAccepted || run.FencingToken >= params.FencingToken { + return ledger.Run{}, false, nil + } + run.State = ledger.StateRunning + run.OwnerID = params.OwnerID + run.FencingToken = params.FencingToken + run.LiveGeneration = params.LiveGeneration + run.OwnerSince = time.Now() + return *run, true, nil +} + +func (f *Store) SetWaitingDecision(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { + return f.transition(runID, token, ledger.StateWaitingDecision) +} + +func (f *Store) Resume(_ context.Context, runID string, token int64) (ledger.Run, bool, error) { + return f.transition(runID, token, ledger.StateRunning) +} + +func (f *Store) transition(runID string, token int64, state ledger.State) (ledger.Run, bool, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + run, ok := f.Runs[runID] + if !ok || run.FencingToken != token || run.State.Terminal() || run.State == ledger.StateFinishing { + return ledger.Run{}, false, nil + } + run.State = state + return *run, true, nil +} + +func (f *Store) PrepareFinish(_ context.Context, params ledger.PrepareFinishParams) (ledger.Run, bool, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + if f.PrepareErr != nil { + return ledger.Run{}, false, f.PrepareErr + } + run, ok := f.Runs[params.RunID] + if !ok || run.FencingToken != params.FencingToken || run.State.Terminal() || + (run.State == ledger.StateWaitingDecision && !params.AllowWaitingDecision) { + return ledger.Run{}, false, nil + } + if run.State != ledger.StateFinishing { + run.State = ledger.StateFinishing + run.ProposedState = params.State + run.ProposedErrorCode = params.ErrorCode + run.ProposedErrorMessage = params.ErrorMessage + run.FinishProposedAt = time.Now() + } + return *run, true, nil +} + +func (f *Store) Finalize(_ context.Context, params ledger.FinalizeParams) (ledger.Run, bool, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + if f.FinalizeErr != nil { + return ledger.Run{}, false, f.FinalizeErr + } + run, ok := f.Runs[params.RunID] + if !ok || run.FencingToken != params.FencingToken || run.State.Terminal() { + return ledger.Run{}, false, nil + } + state := params.State + errorCode := params.ErrorCode + errorMessage := params.ErrorMessage + if run.State == ledger.StateFinishing { + state = run.ProposedState + errorCode = run.ProposedErrorCode + errorMessage = run.ProposedErrorMessage + } else if state == ledger.StateLost && !run.AbortRequestedAt.IsZero() { + state = ledger.StateAborted + errorCode = "" + errorMessage = "" + } + run.State = state + run.ErrorCode = errorCode + run.ErrorMessage = errorMessage + f.Finalized = append(f.Finalized, params) + return *run, true, nil +} + +func (f *Store) RequestAbort(_ context.Context, runID string) (ledger.Run, bool, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + run, ok := f.Runs[runID] + if !ok || run.State.Terminal() || run.State == ledger.StateFinishing { + return ledger.Run{}, false, nil + } + run.AbortRequestedAt = time.Now() + return *run, true, nil +} + +// StaleGenerationRuns mirrors the adapter's keyset sweep: active rows that were +// claimed by an incarnation other than the current one, ordered so a cursor can +// page through them. +func (f *Store) StaleGenerationRuns(_ context.Context, query ledger.StaleGenerationQuery) ([]ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + var matched []ledger.Run + for _, id := range f.Order { + run := *f.Runs[id] + if !run.State.Active() || run.LiveGeneration == "" || run.LiveGeneration == query.CurrentGeneration { + continue + } + if run.LiveGeneration < query.After.LiveGeneration || + (run.LiveGeneration == query.After.LiveGeneration && run.RunID <= query.After.RunID) { + continue + } + matched = append(matched, run) + } + sort.Slice(matched, func(i, j int) bool { + if matched[i].LiveGeneration != matched[j].LiveGeneration { + return matched[i].LiveGeneration < matched[j].LiveGeneration + } + return matched[i].RunID < matched[j].RunID + }) + if query.Limit > 0 && len(matched) > int(query.Limit) { + matched = matched[:query.Limit] + } + return matched, nil +} + +func (f *Store) OrphanedRuns(_ context.Context, query ledger.OrphanQuery) ([]ledger.Run, error) { + f.Mu.Lock() + defer f.Mu.Unlock() + cutoff := time.Now().Add(-query.MinAge) + var matched []ledger.Run + for _, id := range f.Order { + run := *f.Runs[id] + if run.State != ledger.StateAccepted || run.OwnerID != "" || !run.CreatedAt.Before(cutoff) { + continue + } + matched = append(matched, run) + } + if query.Limit > 0 && len(matched) > int(query.Limit) { + matched = matched[:query.Limit] + } + return matched, nil +} + +// insertOrphan records an admission that committed under a process that died +// before it could claim anything. +func (f *Store) InsertOrphan(runID, sessionID, invocationID, fingerprint string) { + f.Mu.Lock() + defer f.Mu.Unlock() + run := &ledger.Run{ + RunID: runID, + BotID: "bot-runtime", + SessionID: sessionID, + InvocationID: invocationID, + TurnID: runID + "-turn", + TurnPosition: 1, + State: ledger.StateAccepted, + InputFingerprint: fingerprint, + CreatedAt: time.Now().Add(-time.Hour), + } + f.Runs[run.RunID] = run + f.Order = append(f.Order, run.RunID) +} + +// insertClaimed records a run that some owner took and never finished, which is +// what the reaper finds after that owner disappears. +func (f *Store) InsertClaimed(runID, sessionID string, token int64, generation string) { + f.Mu.Lock() + defer f.Mu.Unlock() + run := &ledger.Run{ + RunID: runID, + BotID: "bot-runtime", + SessionID: sessionID, + InvocationID: runID + "-inv", + TurnID: runID + "-turn", + TurnPosition: 1, + State: ledger.StateRunning, + OwnerID: "owner-gone", + FencingToken: token, + LiveGeneration: generation, + OwnerSince: time.Now().Add(-time.Minute), + CreatedAt: time.Now().Add(-time.Minute), + } + f.Runs[run.RunID] = run + f.Order = append(f.Order, run.RunID) +} + +func (f *Store) State(runID string) ledger.State { + f.Mu.Lock() + defer f.Mu.Unlock() + run, ok := f.Runs[runID] + if !ok { + return "" + } + return run.State +} + +func (f *Store) ErrorCode(runID string) string { + f.Mu.Lock() + defer f.Mu.Unlock() + run, ok := f.Runs[runID] + if !ok { + return "" + } + return run.ErrorCode +} + +func (f *Store) SetFinalizeErr(err error) { + f.Mu.Lock() + defer f.Mu.Unlock() + f.FinalizeErr = err +} + +func (f *Store) SetPrepareErr(err error) { + f.Mu.Lock() + defer f.Mu.Unlock() + f.PrepareErr = err +} + +func (f *Store) Counts() (admits, claims int) { + f.Mu.Lock() + defer f.Mu.Unlock() + return f.Admits, f.Claims +} + +func (f *Store) TerminalWrites() []ledger.FinalizeParams { + f.Mu.Lock() + defer f.Mu.Unlock() + return append([]ledger.FinalizeParams(nil), f.Finalized...) +} diff --git a/internal/testutil/sessionruntime/runtime.go b/internal/testutil/sessionruntime/runtime.go new file mode 100644 index 000000000..2b02cd75f --- /dev/null +++ b/internal/testutil/sessionruntime/runtime.go @@ -0,0 +1,50 @@ +// Package sessiontest creates admitted runtimes for tests outside the runtime +// package. It uses the public admission path, never a pre-ledger reservation. +package sessiontest + +import ( + "context" + + sessionruntime "github.com/felinics/memoh/internal/agent/runtime/session" + "github.com/felinics/memoh/internal/agent/runtime/session/ledger" + "github.com/felinics/memoh/internal/agent/turn" + "github.com/felinics/memoh/internal/testutil/sessionledger" +) + +type deterministicLedger struct{ *sessionledger.Store } + +// Fixture callers name their run through the invocation argument. Production +// IDs still come from Manager.Admit; deterministic test identities keep events +// and assertions readable without bypassing admission or fencing. +func (s deterministicLedger) Admit(ctx context.Context, params ledger.AdmitParams) (ledger.Run, bool, error) { + params.RunID = params.InvocationID + return s.Store.Admit(ctx, params) +} + +type fence struct{} + +func (fence) Activate(context.Context, string, string, int64) error { return nil } + +func New(backend sessionruntime.Backend, opts sessionruntime.Options) *sessionruntime.Manager { + if opts.Ledger == nil { + opts.Ledger = deterministicLedger{sessionledger.New()} + } + if opts.Fence == nil { + opts.Fence = fence{} + } + return sessionruntime.NewManager(backend, opts) +} + +func Start(ctx context.Context, manager *sessionruntime.Manager, botID, sessionID, invocationID string, abortCh chan<- struct{}, cancel context.CancelFunc, injectCh chan<- turn.InjectMessage) (sessionruntime.RunHandle, error) { + admission, err := manager.Admit(ctx, sessionruntime.AdmitInput{ + BotID: botID, SessionID: sessionID, InvocationID: invocationID, + Payload: []byte(`{"text":"runtime fixture"}`), + Execution: sessionruntime.Execution{ + Admission: func(context.Context, sessionruntime.RunHandle) (sessionruntime.RunAdmissionView, error) { + return sessionruntime.RunAdmissionView{}, nil + }, + AbortCh: abortCh, Cancel: cancel, InjectCh: injectCh, + }, + }) + return admission.Handle, err +} diff --git a/mise.toml b/mise.toml index a5bba7048..933f45834 100644 --- a/mise.toml +++ b/mise.toml @@ -202,14 +202,14 @@ go test \ """ [tasks."test:session-runtime:acceptance:cluster:destructive"] -description = "Run the isolated owner-restart decision black-box serially against the cluster topology" +description = "Run isolated owner, decision, steer, and terminal-proposal crash acceptance serially" env = { MEMOH_SESSION_RUNTIME_ACCEPTANCE = "1", MEMOH_SESSION_RUNTIME_ACCEPTANCE_REQUIRED = "1", MEMOH_SESSION_RUNTIME_ACCEPTANCE_MODE = "cluster", MEMOH_SESSION_RUNTIME_ACCEPTANCE_CRASH = "1" } run = """ go test \ -tags=integration \ -count=1 \ -timeout=5m \ - -run '^TestSRDEC001DecisionPersistsRunAndTurnAcrossRestart$' \ + -run '^Test(SRDEC001DecisionPersistsRunAndTurnAcrossRestart|QueueSteerDecisionSurvivesOwnerRestart|SRDUR001AcceptedInputSurvivesOwnerCrashAsLedgerFact|SRDUR002PreparedFinishSurvivesProcessCrash)$' \ ./internal/agent/runtime/session/acceptance """ diff --git a/packages/sdk/src/@pinia/colada.gen.ts b/packages/sdk/src/@pinia/colada.gen.ts index cda7c83f2..dc96696dd 100644 --- a/packages/sdk/src/@pinia/colada.gen.ts +++ b/packages/sdk/src/@pinia/colada.gen.ts @@ -4,8 +4,8 @@ import { type _JSONValue, defineQueryOptions, type UseMutationOptions } from '@p import { serializeQueryKeyValue } from '../client'; import { client } from '../client.gen'; -import { deleteBotsByBotIdAclRulesByRuleId, deleteBotsByBotIdAcpRuntimesByRuntimeId, deleteBotsByBotIdAgentsById, deleteBotsByBotIdAgentsByIdCredential, deleteBotsByBotIdChannelManagersByChannelIdentityId, deleteBotsByBotIdCompactionLogs, deleteBotsByBotIdConnectorsByConnectionId, deleteBotsByBotIdContainer, deleteBotsByBotIdContainerBrowserSessionsBySessionId, deleteBotsByBotIdContainerDisplaySessionsBySessionId, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdEmailBindingsById, deleteBotsByBotIdMcpById, deleteBotsByBotIdMcpByIdOauthToken, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdMessages, deleteBotsByBotIdScheduleById, deleteBotsByBotIdScheduleLogs, deleteBotsByBotIdSessionsBySessionId, deleteBotsByBotIdSettings, deleteBotsByBotIdSupermarketPackagesByInstallationId, deleteBotsByBotIdUserAccessByGrantId, deleteBotsByBotIdWorkdirsByWorkdirId, deleteBotsByBotIdWorkspaceTargetsByTargetId, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteEmailProvidersById, deleteEmailProvidersByIdOauthToken, deleteFetchProvidersById, deleteMemoryProvidersById, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteProvidersByIdOauthToken, deleteSearchProvidersById, deleteUsersById, deleteUsersMeChannelIdentitiesByChannelIdentityId, deleteUsersMeRuntimesById, getAcpProfiles, getBots, getBotsByBotIdAclChannelIdentities, getBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversations, getBotsByBotIdAclChannelTypesByChannelTypeConversations, getBotsByBotIdAclDefaultEffect, getBotsByBotIdAclRules, getBotsByBotIdAcpRuntimesByRuntimeId, getBotsByBotIdAgents, getBotsByBotIdAgentsById, getBotsByBotIdAgentsByIdCredential, getBotsByBotIdAgentsByIdModels, getBotsByBotIdBackupSummary, getBotsByBotIdChannelManagers, getBotsByBotIdCompactionLogs, getBotsByBotIdConnectors, getBotsByBotIdConnectorsByConnectionId, getBotsByBotIdContainer, getBotsByBotIdContainerDisplay, getBotsByBotIdContainerDisplaySessions, getBotsByBotIdContainerFs, getBotsByBotIdContainerFsDownload, getBotsByBotIdContainerFsList, getBotsByBotIdContainerFsRead, getBotsByBotIdContainerMetrics, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdContainerTerminal, getBotsByBotIdContainerTerminalWs, getBotsByBotIdEmailBindings, getBotsByBotIdEmailOutbox, getBotsByBotIdEmailOutboxById, getBotsByBotIdHooksEvents, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpByIdOauthStatus, getBotsByBotIdMcpOpsExport, getBotsByBotIdMemory, getBotsByBotIdMemoryGraph, getBotsByBotIdMemoryStatus, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdMessagesLocate, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdScheduleByIdLogs, getBotsByBotIdScheduleLogs, getBotsByBotIdSessions, getBotsByBotIdSessionsBySessionId, getBotsByBotIdSessionsBySessionIdAcpRuntime, getBotsByBotIdSessionsBySessionIdContextLifecycle, getBotsByBotIdSessionsBySessionIdStatus, getBotsByBotIdSessionsModelPreferenceSeed, getBotsByBotIdSettings, getBotsByBotIdSkillsCatalog, getBotsByBotIdSupermarketPackages, getBotsByBotIdTokenUsage, getBotsByBotIdTokenUsageRecords, getBotsByBotIdUserAccess, getBotsByBotIdUserAccessCandidates, getBotsByBotIdWebWs, getBotsByBotIdWorkdirs, getBotsByBotIdWorkspaceTargets, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsNameAvailability, getBotsUserAccessCandidates, getChannels, getChannelsByPlatform, getConnectorsCatalog, getEmailOauthCallback, getEmailProviders, getEmailProvidersById, getEmailProvidersByIdOauthAuthorize, getEmailProvidersByIdOauthStatus, getEmailProvidersMeta, getFetchProviders, getFetchProvidersById, getFetchProvidersMeta, getMemoryProviders, getMemoryProvidersById, getMemoryProvidersByIdStatus, getMemoryProvidersMeta, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getOauthMcpCallback, getPing, getProviders, getProvidersById, getProvidersByIdModels, getProvidersByIdOauthAuthorize, getProvidersByIdOauthStatus, getProvidersCount, getProvidersNameByName, getProvidersOauthCallback, getProviderTemplates, getProviderTemplatesById, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getSpeechModels, getSpeechModelsById, getSpeechModelsByIdCapabilities, getSpeechProviders, getSpeechProvidersById, getSpeechProvidersByIdModels, getSpeechProvidersMeta, getSupermarketArtifactsIconByDigest, getSupermarketPackages, getSupermarketRegistries, getSupermarketRegistriesByRegistryIdCategories, getSupermarketRegistriesByRegistryIdPackages, getSupermarketRegistriesByRegistryIdPackagesByPackageId, getSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevision, getSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillId, getSupermarketSkills, getTranscriptionModels, getTranscriptionModelsById, getTranscriptionModelsByIdCapabilities, getTranscriptionProviders, getTranscriptionProvidersById, getTranscriptionProvidersByIdModels, getTranscriptionProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelIdentities, getUsersMeChannelsByPlatform, getUsersMeComputerAccess, getUsersMeRuntimes, getVideoModels, getVideoModelsById, getVideoProviders, getVideoProvidersById, getVideoProvidersByIdModels, getVideoProvidersMeta, getWebhookTunnelStatus, type Options, patchBotsByBotIdAcpRuntimesByRuntimeIdMode, patchBotsByBotIdAcpRuntimesByRuntimeIdModel, patchBotsByBotIdAcpRuntimesByRuntimeIdReasoning, patchBotsByBotIdAgentsById, patchBotsByBotIdConnectorsByConnectionId, patchBotsByBotIdSessionsBySessionId, patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode, patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel, patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning, patchBotsByBotIdWorkdirsByWorkdirId, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postAuthRefresh, postBots, postBotsBackupImport, postBotsBackupImportPreview, postBotsByBotIdAclRules, postBotsByBotIdAcpRuntimes, postBotsByBotIdAgents, postBotsByBotIdAgentsByIdCodexLoginDeviceAuthorize, postBotsByBotIdAgentsByIdCodexLoginDeviceCancel, postBotsByBotIdAgentsByIdCodexLoginDevicePoll, postBotsByBotIdBackupExport, postBotsByBotIdChannelManagers, postBotsByBotIdConnectorsApiKey, postBotsByBotIdConnectorsByConnectionIdReauth, postBotsByBotIdConnectorsOauth, postBotsByBotIdContainer, postBotsByBotIdContainerBrowserSessions, postBotsByBotIdContainerBrowserSessionsBySessionIdKeepalive, postBotsByBotIdContainerDataRestore, postBotsByBotIdContainerDisplayWebrtcOffer, postBotsByBotIdContainerFsArchive, postBotsByBotIdContainerFsDelete, postBotsByBotIdContainerFsExtract, postBotsByBotIdContainerFsMkdir, postBotsByBotIdContainerFsRename, postBotsByBotIdContainerFsUpload, postBotsByBotIdContainerFsWrite, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSkillsActions, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshotsRollback, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdEmailBindings, postBotsByBotIdHooksTest, postBotsByBotIdMcp, postBotsByBotIdMcpByIdOauthAuthorize, postBotsByBotIdMcpByIdOauthDiscover, postBotsByBotIdMcpByIdOauthExchange, postBotsByBotIdMcpByIdProbe, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryIngest, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdQuickActionsExecute, postBotsByBotIdSchedule, postBotsByBotIdSessions, postBotsByBotIdSessionsBySessionIdAcpRuntime, postBotsByBotIdSessionsBySessionIdCompact, postBotsByBotIdSessionsBySessionIdFork, postBotsByBotIdSettings, postBotsByBotIdSupermarketInstallPackage, postBotsByBotIdToolApprovalsByApprovalIdApprove, postBotsByBotIdToolApprovalsByApprovalIdReject, postBotsByBotIdTools, postBotsByBotIdTtsSynthesize, postBotsByBotIdUserAccess, postBotsByBotIdWebMessages, postBotsByBotIdWorkdirs, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postBotsByIdChannelByPlatformWebhookEndpoint, postEmailMailgunWebhookByConfigId, postEmailProviders, postFetchProviders, postMemoryProviders, postModels, postModelsByIdTest, postProviders, postProvidersByIdImportModels, postProvidersByIdOauthPoll, postProvidersByIdTest, postProvidersFromTemplate, postSearchProviders, postSpeechModelsByIdTest, postSpeechProvidersByIdImportModels, postTranscriptionModelsByIdTest, postTranscriptionProvidersByIdImportModels, postUsers, postUsersMeChannelLinks, postUsersMeRuntimes, postVideoProvidersByIdImportModels, putBotsByBotIdAclDefaultEffect, putBotsByBotIdAclRulesByRuleId, putBotsByBotIdAgentsByIdCredential, putBotsByBotIdContainerMetrics, putBotsByBotIdEmailBindingsById, putBotsByBotIdMcpById, putBotsByBotIdMcpOpsImport, putBotsByBotIdMemoryByMemoryId, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdUserAccessByGrantId, putBotsByBotIdWorkspaceTargetsByTargetIdToolApproval, putBotsByBotIdWorkspaceTargetsPrimary, putBotsByBotIdWorkspaceTargetsRemotesByRuntimeId, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdOwner, putEmailProvidersById, putFetchProvidersById, putMemoryProvidersById, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putSpeechModelsById, putTranscriptionModelsById, putUsersById, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword, putVideoModelsById } from '../sdk.gen'; -import type { DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdError, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdError, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialError, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdError, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdError, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsError, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdError, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdError, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdError, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdError, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenError, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesError, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsError, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdError, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponse, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdError, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdError, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdError, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdResponse, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdError, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenError, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdError, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdError, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenError, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteUsersByIdData, DeleteUsersByIdError, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdError, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdError, GetAcpProfilesData, GetAcpProfilesResponse, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsError, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponse, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesError, GetBotsByBotIdAclChannelIdentitiesResponse, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsError, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponse, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectError, GetBotsByBotIdAclDefaultEffectResponse, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesError, GetBotsByBotIdAclRulesResponse, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdError, GetBotsByBotIdAcpRuntimesByRuntimeIdResponse, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialError, GetBotsByBotIdAgentsByIdCredentialResponse, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdError, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsError, GetBotsByBotIdAgentsByIdModelsResponse, GetBotsByBotIdAgentsByIdResponse, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsError, GetBotsByBotIdAgentsResponse, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryError, GetBotsByBotIdBackupSummaryResponse, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersError, GetBotsByBotIdChannelManagersResponse, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsError, GetBotsByBotIdCompactionLogsResponse, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdError, GetBotsByBotIdConnectorsByConnectionIdResponse, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsError, GetBotsByBotIdConnectorsResponse, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayError, GetBotsByBotIdContainerDisplayResponse, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsError, GetBotsByBotIdContainerDisplaySessionsResponse, GetBotsByBotIdContainerError, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadError, GetBotsByBotIdContainerFsError, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListError, GetBotsByBotIdContainerFsListResponse, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadError, GetBotsByBotIdContainerFsReadResponse, GetBotsByBotIdContainerFsResponse, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsError, GetBotsByBotIdContainerMetricsResponse, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsError, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalError, GetBotsByBotIdContainerTerminalResponse, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsError, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsError, GetBotsByBotIdEmailBindingsResponse, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdError, GetBotsByBotIdEmailOutboxByIdResponse, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxError, GetBotsByBotIdEmailOutboxResponse, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsError, GetBotsByBotIdHooksEventsResponse, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusError, GetBotsByBotIdMcpByIdOauthStatusResponse, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportError, GetBotsByBotIdMcpOpsExportResponse, GetBotsByBotIdMcpResponse, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphError, GetBotsByBotIdMemoryGraphResponse, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusError, GetBotsByBotIdMemoryStatusResponse, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateError, GetBotsByBotIdMessagesLocateResponse, GetBotsByBotIdMessagesResponse, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsError, GetBotsByBotIdScheduleByIdLogsResponse, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsError, GetBotsByBotIdScheduleLogsResponse, GetBotsByBotIdScheduleResponse, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeError, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleError, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponse, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdError, GetBotsByBotIdSessionsBySessionIdResponse, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusError, GetBotsByBotIdSessionsBySessionIdStatusResponse, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsError, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedError, GetBotsByBotIdSessionsModelPreferenceSeedResponse, GetBotsByBotIdSessionsResponse, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogError, GetBotsByBotIdSkillsCatalogResponse, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesError, GetBotsByBotIdSupermarketPackagesResponse, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageError, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsError, GetBotsByBotIdTokenUsageRecordsResponse, GetBotsByBotIdTokenUsageResponse, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesError, GetBotsByBotIdUserAccessCandidatesResponse, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessError, GetBotsByBotIdUserAccessResponse, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsError, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsError, GetBotsByBotIdWorkdirsResponse, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsError, GetBotsByBotIdWorkspaceTargetsResponse, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksResponse, GetBotsByIdData, GetBotsByIdError, GetBotsByIdResponse, GetBotsData, GetBotsError, GetBotsNameAvailabilityData, GetBotsNameAvailabilityError, GetBotsNameAvailabilityResponse, GetBotsResponse, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesError, GetBotsUserAccessCandidatesResponse, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformResponse, GetChannelsData, GetChannelsError, GetChannelsResponse, GetConnectorsCatalogData, GetConnectorsCatalogError, GetConnectorsCatalogResponse, GetEmailOauthCallbackData, GetEmailOauthCallbackError, GetEmailOauthCallbackResponse, GetEmailProvidersByIdData, GetEmailProvidersByIdError, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeError, GetEmailProvidersByIdOauthAuthorizeResponse, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusError, GetEmailProvidersByIdOauthStatusResponse, GetEmailProvidersByIdResponse, GetEmailProvidersData, GetEmailProvidersError, GetEmailProvidersMetaData, GetEmailProvidersMetaResponse, GetEmailProvidersResponse, GetFetchProvidersByIdData, GetFetchProvidersByIdError, GetFetchProvidersByIdResponse, GetFetchProvidersData, GetFetchProvidersError, GetFetchProvidersMetaData, GetFetchProvidersMetaResponse, GetFetchProvidersResponse, GetMemoryProvidersByIdData, GetMemoryProvidersByIdError, GetMemoryProvidersByIdResponse, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusError, GetMemoryProvidersByIdStatusResponse, GetMemoryProvidersData, GetMemoryProvidersError, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponse, GetMemoryProvidersResponse, GetModelsByIdData, GetModelsByIdError, GetModelsByIdResponse, GetModelsCountData, GetModelsCountError, GetModelsCountResponse, GetModelsData, GetModelsError, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdResponse, GetModelsResponse, GetOauthMcpCallbackData, GetOauthMcpCallbackError, GetOauthMcpCallbackResponse, GetPingData, GetPingResponse, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsResponse, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeError, GetProvidersByIdOauthAuthorizeResponse, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusError, GetProvidersByIdOauthStatusResponse, GetProvidersByIdResponse, GetProvidersCountData, GetProvidersCountError, GetProvidersCountResponse, GetProvidersData, GetProvidersError, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameResponse, GetProvidersOauthCallbackData, GetProvidersOauthCallbackError, GetProvidersOauthCallbackResponse, GetProvidersResponse, GetProviderTemplatesByIdData, GetProviderTemplatesByIdError, GetProviderTemplatesByIdResponse, GetProviderTemplatesData, GetProviderTemplatesError, GetProviderTemplatesResponse, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdResponse, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersResponse, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesError, GetSpeechModelsByIdCapabilitiesResponse, GetSpeechModelsByIdData, GetSpeechModelsByIdError, GetSpeechModelsByIdResponse, GetSpeechModelsData, GetSpeechModelsError, GetSpeechModelsResponse, GetSpeechProvidersByIdData, GetSpeechProvidersByIdError, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsError, GetSpeechProvidersByIdModelsResponse, GetSpeechProvidersByIdResponse, GetSpeechProvidersData, GetSpeechProvidersError, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponse, GetSpeechProvidersResponse, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestError, GetSupermarketPackagesData, GetSupermarketPackagesError, GetSupermarketPackagesResponse, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesError, GetSupermarketRegistriesByRegistryIdCategoriesResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponse, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesError, GetSupermarketRegistriesByRegistryIdPackagesResponse, GetSupermarketRegistriesData, GetSupermarketRegistriesError, GetSupermarketRegistriesResponse, GetSupermarketSkillsData, GetSupermarketSkillsError, GetSupermarketSkillsResponse, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesError, GetTranscriptionModelsByIdCapabilitiesResponse, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdError, GetTranscriptionModelsByIdResponse, GetTranscriptionModelsData, GetTranscriptionModelsError, GetTranscriptionModelsResponse, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdError, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsError, GetTranscriptionProvidersByIdModelsResponse, GetTranscriptionProvidersByIdResponse, GetTranscriptionProvidersData, GetTranscriptionProvidersError, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponse, GetTranscriptionProvidersResponse, GetUsersByIdData, GetUsersByIdError, GetUsersByIdResponse, GetUsersData, GetUsersError, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesError, GetUsersMeChannelIdentitiesResponse, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformResponse, GetUsersMeComputerAccessData, GetUsersMeComputerAccessError, GetUsersMeComputerAccessResponse, GetUsersMeData, GetUsersMeError, GetUsersMeResponse, GetUsersMeRuntimesData, GetUsersMeRuntimesError, GetUsersMeRuntimesResponse, GetUsersResponse, GetVideoModelsByIdData, GetVideoModelsByIdError, GetVideoModelsByIdResponse, GetVideoModelsData, GetVideoModelsError, GetVideoModelsResponse, GetVideoProvidersByIdData, GetVideoProvidersByIdError, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsError, GetVideoProvidersByIdModelsResponse, GetVideoProvidersByIdResponse, GetVideoProvidersData, GetVideoProvidersError, GetVideoProvidersMetaData, GetVideoProvidersMetaResponse, GetVideoProvidersResponse, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningError, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponse, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdError, PatchBotsByBotIdAgentsByIdResponse, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponse, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdError, PatchBotsByBotIdSessionsBySessionIdResponse, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdError, PatchBotsByBotIdWorkdirsByWorkdirIdResponse, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusResponse, PostAuthLoginData, PostAuthLoginError, PostAuthLoginResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostBotsBackupImportData, PostBotsBackupImportError, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewError, PostBotsBackupImportPreviewResponse, PostBotsBackupImportResponse, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesError, PostBotsByBotIdAclRulesResponse, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesError, PostBotsByBotIdAcpRuntimesResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeError, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponse, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsError, PostBotsByBotIdAgentsResponse, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportError, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersError, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyError, PostBotsByBotIdConnectorsApiKeyResponse, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthError, PostBotsByBotIdConnectorsByConnectionIdReauthResponse, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthError, PostBotsByBotIdConnectorsOauthResponse, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveError, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponse, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsError, PostBotsByBotIdContainerBrowserSessionsResponse, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreError, PostBotsByBotIdContainerDataRestoreResponse, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferError, PostBotsByBotIdContainerDisplayWebrtcOfferResponse, PostBotsByBotIdContainerError, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveError, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteError, PostBotsByBotIdContainerFsDeleteResponse, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractError, PostBotsByBotIdContainerFsExtractResponse, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirError, PostBotsByBotIdContainerFsMkdirResponse, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameError, PostBotsByBotIdContainerFsRenameResponse, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadError, PostBotsByBotIdContainerFsUploadResponse, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteError, PostBotsByBotIdContainerFsWriteResponse, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsError, PostBotsByBotIdContainerSkillsActionsResponse, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackError, PostBotsByBotIdContainerSnapshotsRollbackResponse, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsError, PostBotsByBotIdEmailBindingsResponse, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestError, PostBotsByBotIdHooksTestResponse, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeError, PostBotsByBotIdMcpByIdOauthAuthorizeResponse, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverError, PostBotsByBotIdMcpByIdOauthDiscoverResponse, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeError, PostBotsByBotIdMcpByIdOauthExchangeResponse, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeError, PostBotsByBotIdMcpByIdProbeResponse, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestError, PostBotsByBotIdMemoryIngestResponse, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteError, PostBotsByBotIdQuickActionsExecuteResponse, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleResponse, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeError, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactError, PostBotsByBotIdSessionsBySessionIdCompactResponse, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkError, PostBotsByBotIdSessionsBySessionIdForkResponse, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsError, PostBotsByBotIdSessionsResponse, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageError, PostBotsByBotIdSupermarketInstallPackageResponse, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveError, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponse, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectError, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponse, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsResponse, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeError, PostBotsByBotIdTtsSynthesizeResponse, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessError, PostBotsByBotIdUserAccessResponse, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesResponse, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsError, PostBotsByBotIdWorkdirsResponse, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointError, PostBotsByIdChannelByPlatformWebhookEndpointResponse, PostBotsData, PostBotsError, PostBotsResponse, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdError, PostEmailMailgunWebhookByConfigIdResponse, PostEmailProvidersData, PostEmailProvidersError, PostEmailProvidersResponse, PostFetchProvidersData, PostFetchProvidersError, PostFetchProvidersResponse, PostMemoryProvidersData, PostMemoryProvidersError, PostMemoryProvidersResponse, PostModelsByIdTestData, PostModelsByIdTestError, PostModelsByIdTestResponse, PostModelsData, PostModelsError, PostModelsResponse, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsError, PostProvidersByIdImportModelsResponse, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollError, PostProvidersByIdOauthPollResponse, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestResponse, PostProvidersData, PostProvidersError, PostProvidersFromTemplateData, PostProvidersFromTemplateError, PostProvidersFromTemplateResponse, PostProvidersResponse, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersResponse, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestError, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsError, PostSpeechProvidersByIdImportModelsResponse, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestError, PostTranscriptionModelsByIdTestResponse, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsError, PostTranscriptionProvidersByIdImportModelsResponse, PostUsersData, PostUsersError, PostUsersMeChannelLinksData, PostUsersMeChannelLinksError, PostUsersMeChannelLinksResponse, PostUsersMeRuntimesData, PostUsersMeRuntimesError, PostUsersMeRuntimesResponse, PostUsersResponse, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsError, PostVideoProvidersByIdImportModelsResponse, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectError, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdError, PutBotsByBotIdAclRulesByRuleIdResponse, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialError, PutBotsByBotIdAgentsByIdCredentialResponse, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsError, PutBotsByBotIdContainerMetricsResponse, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdError, PutBotsByBotIdEmailBindingsByIdResponse, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportError, PutBotsByBotIdMcpOpsImportResponse, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdError, PutBotsByBotIdMemoryByMemoryIdResponse, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsResponse, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdError, PutBotsByBotIdUserAccessByGrantIdResponse, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalError, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponse, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformResponse, PutBotsByIdData, PutBotsByIdError, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerResponse, PutBotsByIdResponse, PutEmailProvidersByIdData, PutEmailProvidersByIdError, PutEmailProvidersByIdResponse, PutFetchProvidersByIdData, PutFetchProvidersByIdError, PutFetchProvidersByIdResponse, PutMemoryProvidersByIdData, PutMemoryProvidersByIdError, PutMemoryProvidersByIdResponse, PutModelsByIdData, PutModelsByIdError, PutModelsByIdResponse, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdResponse, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdResponse, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdResponse, PutSpeechModelsByIdData, PutSpeechModelsByIdError, PutSpeechModelsByIdResponse, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdError, PutTranscriptionModelsByIdResponse, PutUsersByIdData, PutUsersByIdError, PutUsersByIdResponse, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformResponse, PutUsersMeData, PutUsersMeError, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMeResponse, PutVideoModelsByIdData, PutVideoModelsByIdError, PutVideoModelsByIdResponse } from '../types.gen'; +import { deleteBotsByBotIdAclRulesByRuleId, deleteBotsByBotIdAcpRuntimesByRuntimeId, deleteBotsByBotIdAgentsById, deleteBotsByBotIdAgentsByIdCredential, deleteBotsByBotIdChannelManagersByChannelIdentityId, deleteBotsByBotIdCompactionLogs, deleteBotsByBotIdConnectorsByConnectionId, deleteBotsByBotIdContainer, deleteBotsByBotIdContainerBrowserSessionsBySessionId, deleteBotsByBotIdContainerDisplaySessionsBySessionId, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdEmailBindingsById, deleteBotsByBotIdMcpById, deleteBotsByBotIdMcpByIdOauthToken, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdMessages, deleteBotsByBotIdScheduleById, deleteBotsByBotIdScheduleLogs, deleteBotsByBotIdSessionsBySessionId, deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId, deleteBotsByBotIdSettings, deleteBotsByBotIdSupermarketPackagesByInstallationId, deleteBotsByBotIdUserAccessByGrantId, deleteBotsByBotIdWorkdirsByWorkdirId, deleteBotsByBotIdWorkspaceTargetsByTargetId, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteEmailProvidersById, deleteEmailProvidersByIdOauthToken, deleteFetchProvidersById, deleteMemoryProvidersById, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteProvidersByIdOauthToken, deleteSearchProvidersById, deleteUsersById, deleteUsersMeChannelIdentitiesByChannelIdentityId, deleteUsersMeRuntimesById, getAcpProfiles, getBots, getBotsByBotIdAclChannelIdentities, getBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversations, getBotsByBotIdAclChannelTypesByChannelTypeConversations, getBotsByBotIdAclDefaultEffect, getBotsByBotIdAclRules, getBotsByBotIdAcpRuntimesByRuntimeId, getBotsByBotIdAgents, getBotsByBotIdAgentsById, getBotsByBotIdAgentsByIdCredential, getBotsByBotIdAgentsByIdModels, getBotsByBotIdBackupSummary, getBotsByBotIdChannelManagers, getBotsByBotIdCompactionLogs, getBotsByBotIdConnectors, getBotsByBotIdConnectorsByConnectionId, getBotsByBotIdContainer, getBotsByBotIdContainerDisplay, getBotsByBotIdContainerDisplaySessions, getBotsByBotIdContainerFs, getBotsByBotIdContainerFsDownload, getBotsByBotIdContainerFsList, getBotsByBotIdContainerFsRead, getBotsByBotIdContainerMetrics, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdContainerTerminal, getBotsByBotIdContainerTerminalWs, getBotsByBotIdEmailBindings, getBotsByBotIdEmailOutbox, getBotsByBotIdEmailOutboxById, getBotsByBotIdHooksEvents, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpByIdOauthStatus, getBotsByBotIdMcpOpsExport, getBotsByBotIdMemory, getBotsByBotIdMemoryGraph, getBotsByBotIdMemoryStatus, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdMessagesLocate, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdScheduleByIdLogs, getBotsByBotIdScheduleLogs, getBotsByBotIdSessions, getBotsByBotIdSessionsBySessionId, getBotsByBotIdSessionsBySessionIdAcpRuntime, getBotsByBotIdSessionsBySessionIdContextLifecycle, getBotsByBotIdSessionsBySessionIdFollowUpQueue, getBotsByBotIdSessionsBySessionIdQueue, getBotsByBotIdSessionsBySessionIdStatus, getBotsByBotIdSessionsBySessionIdSteerQueue, getBotsByBotIdSessionsModelPreferenceSeed, getBotsByBotIdSettings, getBotsByBotIdSkillsCatalog, getBotsByBotIdSupermarketPackages, getBotsByBotIdTokenUsage, getBotsByBotIdTokenUsageRecords, getBotsByBotIdUserAccess, getBotsByBotIdUserAccessCandidates, getBotsByBotIdWebWs, getBotsByBotIdWorkdirs, getBotsByBotIdWorkspaceTargets, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsNameAvailability, getBotsUserAccessCandidates, getChannels, getChannelsByPlatform, getConnectorsCatalog, getEmailOauthCallback, getEmailProviders, getEmailProvidersById, getEmailProvidersByIdOauthAuthorize, getEmailProvidersByIdOauthStatus, getEmailProvidersMeta, getFetchProviders, getFetchProvidersById, getFetchProvidersMeta, getMemoryProviders, getMemoryProvidersById, getMemoryProvidersByIdStatus, getMemoryProvidersMeta, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getOauthMcpCallback, getPing, getProviders, getProvidersById, getProvidersByIdModels, getProvidersByIdOauthAuthorize, getProvidersByIdOauthStatus, getProvidersCount, getProvidersNameByName, getProvidersOauthCallback, getProviderTemplates, getProviderTemplatesById, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getSpeechModels, getSpeechModelsById, getSpeechModelsByIdCapabilities, getSpeechProviders, getSpeechProvidersById, getSpeechProvidersByIdModels, getSpeechProvidersMeta, getSupermarketArtifactsIconByDigest, getSupermarketPackages, getSupermarketRegistries, getSupermarketRegistriesByRegistryIdCategories, getSupermarketRegistriesByRegistryIdPackages, getSupermarketRegistriesByRegistryIdPackagesByPackageId, getSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevision, getSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillId, getSupermarketSkills, getTranscriptionModels, getTranscriptionModelsById, getTranscriptionModelsByIdCapabilities, getTranscriptionProviders, getTranscriptionProvidersById, getTranscriptionProvidersByIdModels, getTranscriptionProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelIdentities, getUsersMeChannelsByPlatform, getUsersMeComputerAccess, getUsersMeRuntimes, getVideoModels, getVideoModelsById, getVideoProviders, getVideoProvidersById, getVideoProvidersByIdModels, getVideoProvidersMeta, getWebhookTunnelStatus, type Options, patchBotsByBotIdAcpRuntimesByRuntimeIdMode, patchBotsByBotIdAcpRuntimesByRuntimeIdModel, patchBotsByBotIdAcpRuntimesByRuntimeIdReasoning, patchBotsByBotIdAgentsById, patchBotsByBotIdConnectorsByConnectionId, patchBotsByBotIdSessionsBySessionId, patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode, patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel, patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning, patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId, patchBotsByBotIdWorkdirsByWorkdirId, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postAuthRefresh, postBots, postBotsBackupImport, postBotsBackupImportPreview, postBotsByBotIdAclRules, postBotsByBotIdAcpRuntimes, postBotsByBotIdAgents, postBotsByBotIdAgentsByIdCodexLoginDeviceAuthorize, postBotsByBotIdAgentsByIdCodexLoginDeviceCancel, postBotsByBotIdAgentsByIdCodexLoginDevicePoll, postBotsByBotIdBackupExport, postBotsByBotIdChannelManagers, postBotsByBotIdConnectorsApiKey, postBotsByBotIdConnectorsByConnectionIdReauth, postBotsByBotIdConnectorsOauth, postBotsByBotIdContainer, postBotsByBotIdContainerBrowserSessions, postBotsByBotIdContainerBrowserSessionsBySessionIdKeepalive, postBotsByBotIdContainerDataRestore, postBotsByBotIdContainerDisplayWebrtcOffer, postBotsByBotIdContainerFsArchive, postBotsByBotIdContainerFsDelete, postBotsByBotIdContainerFsExtract, postBotsByBotIdContainerFsMkdir, postBotsByBotIdContainerFsRename, postBotsByBotIdContainerFsUpload, postBotsByBotIdContainerFsWrite, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSkillsActions, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshotsRollback, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdEmailBindings, postBotsByBotIdHooksTest, postBotsByBotIdMcp, postBotsByBotIdMcpByIdOauthAuthorize, postBotsByBotIdMcpByIdOauthDiscover, postBotsByBotIdMcpByIdOauthExchange, postBotsByBotIdMcpByIdProbe, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryIngest, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdQuickActionsExecute, postBotsByBotIdSchedule, postBotsByBotIdSessions, postBotsByBotIdSessionsBySessionIdAcpRuntime, postBotsByBotIdSessionsBySessionIdCompact, postBotsByBotIdSessionsBySessionIdFollowUpQueue, postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer, postBotsByBotIdSessionsBySessionIdFork, postBotsByBotIdSessionsBySessionIdSteerQueue, postBotsByBotIdSettings, postBotsByBotIdSupermarketInstallPackage, postBotsByBotIdToolApprovalsByApprovalIdApprove, postBotsByBotIdToolApprovalsByApprovalIdReject, postBotsByBotIdTools, postBotsByBotIdTtsSynthesize, postBotsByBotIdUserAccess, postBotsByBotIdWebMessages, postBotsByBotIdWorkdirs, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postBotsByIdChannelByPlatformWebhookEndpoint, postEmailMailgunWebhookByConfigId, postEmailProviders, postFetchProviders, postMemoryProviders, postModels, postModelsByIdTest, postProviders, postProvidersByIdImportModels, postProvidersByIdOauthPoll, postProvidersByIdTest, postProvidersFromTemplate, postSearchProviders, postSpeechModelsByIdTest, postSpeechProvidersByIdImportModels, postTranscriptionModelsByIdTest, postTranscriptionProvidersByIdImportModels, postUsers, postUsersMeChannelLinks, postUsersMeRuntimes, postVideoProvidersByIdImportModels, putBotsByBotIdAclDefaultEffect, putBotsByBotIdAclRulesByRuleId, putBotsByBotIdAgentsByIdCredential, putBotsByBotIdContainerMetrics, putBotsByBotIdEmailBindingsById, putBotsByBotIdMcpById, putBotsByBotIdMcpOpsImport, putBotsByBotIdMemoryByMemoryId, putBotsByBotIdScheduleById, putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder, putBotsByBotIdSessionsBySessionIdSteerQueueReorder, putBotsByBotIdSettings, putBotsByBotIdUserAccessByGrantId, putBotsByBotIdWorkspaceTargetsByTargetIdToolApproval, putBotsByBotIdWorkspaceTargetsPrimary, putBotsByBotIdWorkspaceTargetsRemotesByRuntimeId, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdOwner, putEmailProvidersById, putFetchProvidersById, putMemoryProvidersById, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putSpeechModelsById, putTranscriptionModelsById, putUsersById, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword, putVideoModelsById } from '../sdk.gen'; +import type { DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdError, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdError, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialError, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdError, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdError, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsError, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdError, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdError, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdError, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdError, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenError, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesError, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsError, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdError, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponse, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdError, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdError, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdError, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdResponse, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdError, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenError, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdError, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdError, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenError, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteUsersByIdData, DeleteUsersByIdError, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdError, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdError, GetAcpProfilesData, GetAcpProfilesResponse, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsError, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponse, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesError, GetBotsByBotIdAclChannelIdentitiesResponse, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsError, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponse, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectError, GetBotsByBotIdAclDefaultEffectResponse, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesError, GetBotsByBotIdAclRulesResponse, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdError, GetBotsByBotIdAcpRuntimesByRuntimeIdResponse, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialError, GetBotsByBotIdAgentsByIdCredentialResponse, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdError, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsError, GetBotsByBotIdAgentsByIdModelsResponse, GetBotsByBotIdAgentsByIdResponse, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsError, GetBotsByBotIdAgentsResponse, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryError, GetBotsByBotIdBackupSummaryResponse, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersError, GetBotsByBotIdChannelManagersResponse, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsError, GetBotsByBotIdCompactionLogsResponse, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdError, GetBotsByBotIdConnectorsByConnectionIdResponse, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsError, GetBotsByBotIdConnectorsResponse, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayError, GetBotsByBotIdContainerDisplayResponse, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsError, GetBotsByBotIdContainerDisplaySessionsResponse, GetBotsByBotIdContainerError, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadError, GetBotsByBotIdContainerFsError, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListError, GetBotsByBotIdContainerFsListResponse, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadError, GetBotsByBotIdContainerFsReadResponse, GetBotsByBotIdContainerFsResponse, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsError, GetBotsByBotIdContainerMetricsResponse, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsError, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalError, GetBotsByBotIdContainerTerminalResponse, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsError, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsError, GetBotsByBotIdEmailBindingsResponse, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdError, GetBotsByBotIdEmailOutboxByIdResponse, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxError, GetBotsByBotIdEmailOutboxResponse, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsError, GetBotsByBotIdHooksEventsResponse, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusError, GetBotsByBotIdMcpByIdOauthStatusResponse, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportError, GetBotsByBotIdMcpOpsExportResponse, GetBotsByBotIdMcpResponse, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphError, GetBotsByBotIdMemoryGraphResponse, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusError, GetBotsByBotIdMemoryStatusResponse, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateError, GetBotsByBotIdMessagesLocateResponse, GetBotsByBotIdMessagesResponse, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsError, GetBotsByBotIdScheduleByIdLogsResponse, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsError, GetBotsByBotIdScheduleLogsResponse, GetBotsByBotIdScheduleResponse, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeError, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleError, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponse, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdError, GetBotsByBotIdSessionsBySessionIdFollowUpQueueData, GetBotsByBotIdSessionsBySessionIdFollowUpQueueError, GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponse, GetBotsByBotIdSessionsBySessionIdQueueData, GetBotsByBotIdSessionsBySessionIdQueueError, GetBotsByBotIdSessionsBySessionIdQueueResponse, GetBotsByBotIdSessionsBySessionIdResponse, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusError, GetBotsByBotIdSessionsBySessionIdStatusResponse, GetBotsByBotIdSessionsBySessionIdSteerQueueData, GetBotsByBotIdSessionsBySessionIdSteerQueueError, GetBotsByBotIdSessionsBySessionIdSteerQueueResponse, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsError, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedError, GetBotsByBotIdSessionsModelPreferenceSeedResponse, GetBotsByBotIdSessionsResponse, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogError, GetBotsByBotIdSkillsCatalogResponse, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesError, GetBotsByBotIdSupermarketPackagesResponse, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageError, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsError, GetBotsByBotIdTokenUsageRecordsResponse, GetBotsByBotIdTokenUsageResponse, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesError, GetBotsByBotIdUserAccessCandidatesResponse, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessError, GetBotsByBotIdUserAccessResponse, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsError, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsError, GetBotsByBotIdWorkdirsResponse, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsError, GetBotsByBotIdWorkspaceTargetsResponse, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksResponse, GetBotsByIdData, GetBotsByIdError, GetBotsByIdResponse, GetBotsData, GetBotsError, GetBotsNameAvailabilityData, GetBotsNameAvailabilityError, GetBotsNameAvailabilityResponse, GetBotsResponse, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesError, GetBotsUserAccessCandidatesResponse, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformResponse, GetChannelsData, GetChannelsError, GetChannelsResponse, GetConnectorsCatalogData, GetConnectorsCatalogError, GetConnectorsCatalogResponse, GetEmailOauthCallbackData, GetEmailOauthCallbackError, GetEmailOauthCallbackResponse, GetEmailProvidersByIdData, GetEmailProvidersByIdError, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeError, GetEmailProvidersByIdOauthAuthorizeResponse, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusError, GetEmailProvidersByIdOauthStatusResponse, GetEmailProvidersByIdResponse, GetEmailProvidersData, GetEmailProvidersError, GetEmailProvidersMetaData, GetEmailProvidersMetaResponse, GetEmailProvidersResponse, GetFetchProvidersByIdData, GetFetchProvidersByIdError, GetFetchProvidersByIdResponse, GetFetchProvidersData, GetFetchProvidersError, GetFetchProvidersMetaData, GetFetchProvidersMetaResponse, GetFetchProvidersResponse, GetMemoryProvidersByIdData, GetMemoryProvidersByIdError, GetMemoryProvidersByIdResponse, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusError, GetMemoryProvidersByIdStatusResponse, GetMemoryProvidersData, GetMemoryProvidersError, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponse, GetMemoryProvidersResponse, GetModelsByIdData, GetModelsByIdError, GetModelsByIdResponse, GetModelsCountData, GetModelsCountError, GetModelsCountResponse, GetModelsData, GetModelsError, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdResponse, GetModelsResponse, GetOauthMcpCallbackData, GetOauthMcpCallbackError, GetOauthMcpCallbackResponse, GetPingData, GetPingResponse, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsResponse, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeError, GetProvidersByIdOauthAuthorizeResponse, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusError, GetProvidersByIdOauthStatusResponse, GetProvidersByIdResponse, GetProvidersCountData, GetProvidersCountError, GetProvidersCountResponse, GetProvidersData, GetProvidersError, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameResponse, GetProvidersOauthCallbackData, GetProvidersOauthCallbackError, GetProvidersOauthCallbackResponse, GetProvidersResponse, GetProviderTemplatesByIdData, GetProviderTemplatesByIdError, GetProviderTemplatesByIdResponse, GetProviderTemplatesData, GetProviderTemplatesError, GetProviderTemplatesResponse, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdResponse, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersResponse, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesError, GetSpeechModelsByIdCapabilitiesResponse, GetSpeechModelsByIdData, GetSpeechModelsByIdError, GetSpeechModelsByIdResponse, GetSpeechModelsData, GetSpeechModelsError, GetSpeechModelsResponse, GetSpeechProvidersByIdData, GetSpeechProvidersByIdError, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsError, GetSpeechProvidersByIdModelsResponse, GetSpeechProvidersByIdResponse, GetSpeechProvidersData, GetSpeechProvidersError, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponse, GetSpeechProvidersResponse, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestError, GetSupermarketPackagesData, GetSupermarketPackagesError, GetSupermarketPackagesResponse, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesError, GetSupermarketRegistriesByRegistryIdCategoriesResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponse, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesError, GetSupermarketRegistriesByRegistryIdPackagesResponse, GetSupermarketRegistriesData, GetSupermarketRegistriesError, GetSupermarketRegistriesResponse, GetSupermarketSkillsData, GetSupermarketSkillsError, GetSupermarketSkillsResponse, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesError, GetTranscriptionModelsByIdCapabilitiesResponse, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdError, GetTranscriptionModelsByIdResponse, GetTranscriptionModelsData, GetTranscriptionModelsError, GetTranscriptionModelsResponse, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdError, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsError, GetTranscriptionProvidersByIdModelsResponse, GetTranscriptionProvidersByIdResponse, GetTranscriptionProvidersData, GetTranscriptionProvidersError, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponse, GetTranscriptionProvidersResponse, GetUsersByIdData, GetUsersByIdError, GetUsersByIdResponse, GetUsersData, GetUsersError, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesError, GetUsersMeChannelIdentitiesResponse, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformResponse, GetUsersMeComputerAccessData, GetUsersMeComputerAccessError, GetUsersMeComputerAccessResponse, GetUsersMeData, GetUsersMeError, GetUsersMeResponse, GetUsersMeRuntimesData, GetUsersMeRuntimesError, GetUsersMeRuntimesResponse, GetUsersResponse, GetVideoModelsByIdData, GetVideoModelsByIdError, GetVideoModelsByIdResponse, GetVideoModelsData, GetVideoModelsError, GetVideoModelsResponse, GetVideoProvidersByIdData, GetVideoProvidersByIdError, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsError, GetVideoProvidersByIdModelsResponse, GetVideoProvidersByIdResponse, GetVideoProvidersData, GetVideoProvidersError, GetVideoProvidersMetaData, GetVideoProvidersMetaResponse, GetVideoProvidersResponse, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningError, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponse, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdError, PatchBotsByBotIdAgentsByIdResponse, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponse, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdError, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponse, PatchBotsByBotIdSessionsBySessionIdResponse, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponse, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdError, PatchBotsByBotIdWorkdirsByWorkdirIdResponse, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusResponse, PostAuthLoginData, PostAuthLoginError, PostAuthLoginResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostBotsBackupImportData, PostBotsBackupImportError, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewError, PostBotsBackupImportPreviewResponse, PostBotsBackupImportResponse, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesError, PostBotsByBotIdAclRulesResponse, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesError, PostBotsByBotIdAcpRuntimesResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeError, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponse, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsError, PostBotsByBotIdAgentsResponse, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportError, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersError, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyError, PostBotsByBotIdConnectorsApiKeyResponse, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthError, PostBotsByBotIdConnectorsByConnectionIdReauthResponse, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthError, PostBotsByBotIdConnectorsOauthResponse, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveError, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponse, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsError, PostBotsByBotIdContainerBrowserSessionsResponse, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreError, PostBotsByBotIdContainerDataRestoreResponse, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferError, PostBotsByBotIdContainerDisplayWebrtcOfferResponse, PostBotsByBotIdContainerError, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveError, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteError, PostBotsByBotIdContainerFsDeleteResponse, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractError, PostBotsByBotIdContainerFsExtractResponse, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirError, PostBotsByBotIdContainerFsMkdirResponse, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameError, PostBotsByBotIdContainerFsRenameResponse, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadError, PostBotsByBotIdContainerFsUploadResponse, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteError, PostBotsByBotIdContainerFsWriteResponse, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsError, PostBotsByBotIdContainerSkillsActionsResponse, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackError, PostBotsByBotIdContainerSnapshotsRollbackResponse, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsError, PostBotsByBotIdEmailBindingsResponse, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestError, PostBotsByBotIdHooksTestResponse, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeError, PostBotsByBotIdMcpByIdOauthAuthorizeResponse, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverError, PostBotsByBotIdMcpByIdOauthDiscoverResponse, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeError, PostBotsByBotIdMcpByIdOauthExchangeResponse, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeError, PostBotsByBotIdMcpByIdProbeResponse, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestError, PostBotsByBotIdMemoryIngestResponse, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteError, PostBotsByBotIdQuickActionsExecuteResponse, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleResponse, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeError, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactError, PostBotsByBotIdSessionsBySessionIdCompactResponse, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerError, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponse, PostBotsByBotIdSessionsBySessionIdFollowUpQueueData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueError, PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponse, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkError, PostBotsByBotIdSessionsBySessionIdForkResponse, PostBotsByBotIdSessionsBySessionIdSteerQueueData, PostBotsByBotIdSessionsBySessionIdSteerQueueError, PostBotsByBotIdSessionsBySessionIdSteerQueueResponse, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsError, PostBotsByBotIdSessionsResponse, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageError, PostBotsByBotIdSupermarketInstallPackageResponse, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveError, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponse, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectError, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponse, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsResponse, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeError, PostBotsByBotIdTtsSynthesizeResponse, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessError, PostBotsByBotIdUserAccessResponse, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesResponse, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsError, PostBotsByBotIdWorkdirsResponse, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointError, PostBotsByIdChannelByPlatformWebhookEndpointResponse, PostBotsData, PostBotsError, PostBotsResponse, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdError, PostEmailMailgunWebhookByConfigIdResponse, PostEmailProvidersData, PostEmailProvidersError, PostEmailProvidersResponse, PostFetchProvidersData, PostFetchProvidersError, PostFetchProvidersResponse, PostMemoryProvidersData, PostMemoryProvidersError, PostMemoryProvidersResponse, PostModelsByIdTestData, PostModelsByIdTestError, PostModelsByIdTestResponse, PostModelsData, PostModelsError, PostModelsResponse, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsError, PostProvidersByIdImportModelsResponse, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollError, PostProvidersByIdOauthPollResponse, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestResponse, PostProvidersData, PostProvidersError, PostProvidersFromTemplateData, PostProvidersFromTemplateError, PostProvidersFromTemplateResponse, PostProvidersResponse, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersResponse, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestError, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsError, PostSpeechProvidersByIdImportModelsResponse, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestError, PostTranscriptionModelsByIdTestResponse, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsError, PostTranscriptionProvidersByIdImportModelsResponse, PostUsersData, PostUsersError, PostUsersMeChannelLinksData, PostUsersMeChannelLinksError, PostUsersMeChannelLinksResponse, PostUsersMeRuntimesData, PostUsersMeRuntimesError, PostUsersMeRuntimesResponse, PostUsersResponse, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsError, PostVideoProvidersByIdImportModelsResponse, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectError, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdError, PutBotsByBotIdAclRulesByRuleIdResponse, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialError, PutBotsByBotIdAgentsByIdCredentialResponse, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsError, PutBotsByBotIdContainerMetricsResponse, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdError, PutBotsByBotIdEmailBindingsByIdResponse, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportError, PutBotsByBotIdMcpOpsImportResponse, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdError, PutBotsByBotIdMemoryByMemoryIdResponse, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderData, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderError, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponse, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderData, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderError, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponse, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsResponse, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdError, PutBotsByBotIdUserAccessByGrantIdResponse, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalError, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponse, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformResponse, PutBotsByIdData, PutBotsByIdError, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerResponse, PutBotsByIdResponse, PutEmailProvidersByIdData, PutEmailProvidersByIdError, PutEmailProvidersByIdResponse, PutFetchProvidersByIdData, PutFetchProvidersByIdError, PutFetchProvidersByIdResponse, PutMemoryProvidersByIdData, PutMemoryProvidersByIdError, PutMemoryProvidersByIdResponse, PutModelsByIdData, PutModelsByIdError, PutModelsByIdResponse, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdResponse, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdResponse, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdResponse, PutSpeechModelsByIdData, PutSpeechModelsByIdError, PutSpeechModelsByIdResponse, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdError, PutTranscriptionModelsByIdResponse, PutUsersByIdData, PutUsersByIdError, PutUsersByIdResponse, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformResponse, PutUsersMeData, PutUsersMeError, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMeResponse, PutVideoModelsByIdData, PutVideoModelsByIdError, PutVideoModelsByIdResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -2437,6 +2437,93 @@ export const getBotsByBotIdSessionsBySessionIdContextLifecycleQuery = defineQuer } })); +export const getBotsByBotIdSessionsBySessionIdFollowUpQueueQueryKey = (options: Options) => createQueryKey('getBotsByBotIdSessionsBySessionIdFollowUpQueue', options); + +/** + * List pending follow-up inputs + */ +export const getBotsByBotIdSessionsBySessionIdFollowUpQueueQuery = defineQueryOptions, GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponse, GetBotsByBotIdSessionsBySessionIdFollowUpQueueError>((options: Options) => ({ + key: getBotsByBotIdSessionsBySessionIdFollowUpQueueQueryKey(options), + query: async (context) => { + const { data } = await getBotsByBotIdSessionsBySessionIdFollowUpQueue({ + ...options, + ...context, + throwOnError: true + }); + return data; + } +})); + +/** + * Enqueue follow-up input for the active session run + */ +export const postBotsByBotIdSessionsBySessionIdFollowUpQueueMutation = (options?: Partial>): UseMutationOptions, PostBotsByBotIdSessionsBySessionIdFollowUpQueueError> => ({ + mutation: async (vars) => { + const { data } = await postBotsByBotIdSessionsBySessionIdFollowUpQueue({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Reorder accepted follow-up inputs + */ +export const putBotsByBotIdSessionsBySessionIdFollowUpQueueReorderMutation = (options?: Partial>): UseMutationOptions, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderError> => ({ + mutation: async (vars) => { + const { data } = await putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Cancel an accepted follow-up input + */ +export const deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdMutation = (options?: Partial>): UseMutationOptions, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError> => ({ + mutation: async (vars) => { + const { data } = await deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Edit an accepted follow-up input + */ +export const patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdMutation = (options?: Partial>): UseMutationOptions, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError> => ({ + mutation: async (vars) => { + const { data } = await patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Promote an accepted follow-up input to steer the active run + */ +export const postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerMutation = (options?: Partial>): UseMutationOptions, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerError> => ({ + mutation: async (vars) => { + const { data } = await postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + /** * Fork a chat session from an assistant reply */ @@ -2451,6 +2538,23 @@ export const postBotsByBotIdSessionsBySessionIdForkMutation = (options?: Partial } }); +export const getBotsByBotIdSessionsBySessionIdQueueQueryKey = (options: Options) => createQueryKey('getBotsByBotIdSessionsBySessionIdQueue', options); + +/** + * List pending steer and follow-up inputs in one response + */ +export const getBotsByBotIdSessionsBySessionIdQueueQuery = defineQueryOptions, GetBotsByBotIdSessionsBySessionIdQueueResponse, GetBotsByBotIdSessionsBySessionIdQueueError>((options: Options) => ({ + key: getBotsByBotIdSessionsBySessionIdQueueQueryKey(options), + query: async (context) => { + const { data } = await getBotsByBotIdSessionsBySessionIdQueue({ + ...options, + ...context, + throwOnError: true + }); + return data; + } +})); + export const getBotsByBotIdSessionsBySessionIdStatusQueryKey = (options: Options) => createQueryKey('getBotsByBotIdSessionsBySessionIdStatus', options); /** @@ -2470,6 +2574,79 @@ export const getBotsByBotIdSessionsBySessionIdStatusQuery = defineQueryOptions) => createQueryKey('getBotsByBotIdSessionsBySessionIdSteerQueue', options); + +/** + * List pending steer inputs + */ +export const getBotsByBotIdSessionsBySessionIdSteerQueueQuery = defineQueryOptions, GetBotsByBotIdSessionsBySessionIdSteerQueueResponse, GetBotsByBotIdSessionsBySessionIdSteerQueueError>((options: Options) => ({ + key: getBotsByBotIdSessionsBySessionIdSteerQueueQueryKey(options), + query: async (context) => { + const { data } = await getBotsByBotIdSessionsBySessionIdSteerQueue({ + ...options, + ...context, + throwOnError: true + }); + return data; + } +})); + +/** + * Enqueue steer input for the active session run + */ +export const postBotsByBotIdSessionsBySessionIdSteerQueueMutation = (options?: Partial>): UseMutationOptions, PostBotsByBotIdSessionsBySessionIdSteerQueueError> => ({ + mutation: async (vars) => { + const { data } = await postBotsByBotIdSessionsBySessionIdSteerQueue({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Reorder accepted steer inputs + */ +export const putBotsByBotIdSessionsBySessionIdSteerQueueReorderMutation = (options?: Partial>): UseMutationOptions, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderError> => ({ + mutation: async (vars) => { + const { data } = await putBotsByBotIdSessionsBySessionIdSteerQueueReorder({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Cancel an accepted steer input + */ +export const deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdMutation = (options?: Partial>): UseMutationOptions, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError> => ({ + mutation: async (vars) => { + const { data } = await deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + +/** + * Edit an accepted steer input + */ +export const patchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdMutation = (options?: Partial>): UseMutationOptions, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError> => ({ + mutation: async (vars) => { + const { data } = await patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + /** * Delete user settings * diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c01fa40ce..2523e04b0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { deleteBotsByBotIdAclRulesByRuleId, deleteBotsByBotIdAcpRuntimesByRuntimeId, deleteBotsByBotIdAgentsById, deleteBotsByBotIdAgentsByIdCredential, deleteBotsByBotIdChannelManagersByChannelIdentityId, deleteBotsByBotIdCompactionLogs, deleteBotsByBotIdConnectorsByConnectionId, deleteBotsByBotIdContainer, deleteBotsByBotIdContainerBrowserSessionsBySessionId, deleteBotsByBotIdContainerDisplaySessionsBySessionId, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdEmailBindingsById, deleteBotsByBotIdMcpById, deleteBotsByBotIdMcpByIdOauthToken, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdMessages, deleteBotsByBotIdScheduleById, deleteBotsByBotIdScheduleLogs, deleteBotsByBotIdSessionsBySessionId, deleteBotsByBotIdSettings, deleteBotsByBotIdSupermarketPackagesByInstallationId, deleteBotsByBotIdUserAccessByGrantId, deleteBotsByBotIdWorkdirsByWorkdirId, deleteBotsByBotIdWorkspaceTargetsByTargetId, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteEmailProvidersById, deleteEmailProvidersByIdOauthToken, deleteFetchProvidersById, deleteMemoryProvidersById, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteProvidersByIdOauthToken, deleteSearchProvidersById, deleteUsersById, deleteUsersMeChannelIdentitiesByChannelIdentityId, deleteUsersMeRuntimesById, getAcpProfiles, getBots, getBotsByBotIdAclChannelIdentities, getBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversations, getBotsByBotIdAclChannelTypesByChannelTypeConversations, getBotsByBotIdAclDefaultEffect, getBotsByBotIdAclRules, getBotsByBotIdAcpRuntimesByRuntimeId, getBotsByBotIdAgents, getBotsByBotIdAgentsById, getBotsByBotIdAgentsByIdCredential, getBotsByBotIdAgentsByIdModels, getBotsByBotIdBackupSummary, getBotsByBotIdChannelManagers, getBotsByBotIdCompactionLogs, getBotsByBotIdConnectors, getBotsByBotIdConnectorsByConnectionId, getBotsByBotIdContainer, getBotsByBotIdContainerDisplay, getBotsByBotIdContainerDisplaySessions, getBotsByBotIdContainerFs, getBotsByBotIdContainerFsDownload, getBotsByBotIdContainerFsList, getBotsByBotIdContainerFsRead, getBotsByBotIdContainerMetrics, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdContainerTerminal, getBotsByBotIdContainerTerminalWs, getBotsByBotIdEmailBindings, getBotsByBotIdEmailOutbox, getBotsByBotIdEmailOutboxById, getBotsByBotIdHooksEvents, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpByIdOauthStatus, getBotsByBotIdMcpOpsExport, getBotsByBotIdMemory, getBotsByBotIdMemoryGraph, getBotsByBotIdMemoryStatus, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdMessagesLocate, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdScheduleByIdLogs, getBotsByBotIdScheduleLogs, getBotsByBotIdSessions, getBotsByBotIdSessionsBySessionId, getBotsByBotIdSessionsBySessionIdAcpRuntime, getBotsByBotIdSessionsBySessionIdContextLifecycle, getBotsByBotIdSessionsBySessionIdStatus, getBotsByBotIdSessionsEvents, getBotsByBotIdSessionsModelPreferenceSeed, getBotsByBotIdSettings, getBotsByBotIdSkillsCatalog, getBotsByBotIdSupermarketPackages, getBotsByBotIdTokenUsage, getBotsByBotIdTokenUsageRecords, getBotsByBotIdUserAccess, getBotsByBotIdUserAccessCandidates, getBotsByBotIdWebStream, getBotsByBotIdWebWs, getBotsByBotIdWorkdirs, getBotsByBotIdWorkspaceTargets, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsNameAvailability, getBotsUserAccessCandidates, getChannels, getChannelsByPlatform, getConnectorsCatalog, getEmailOauthCallback, getEmailProviders, getEmailProvidersById, getEmailProvidersByIdOauthAuthorize, getEmailProvidersByIdOauthStatus, getEmailProvidersMeta, getFetchProviders, getFetchProvidersById, getFetchProvidersMeta, getMemoryProviders, getMemoryProvidersById, getMemoryProvidersByIdStatus, getMemoryProvidersMeta, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getOauthMcpCallback, getPing, getProviders, getProvidersById, getProvidersByIdModels, getProvidersByIdOauthAuthorize, getProvidersByIdOauthStatus, getProvidersCount, getProvidersNameByName, getProvidersOauthCallback, getProviderTemplates, getProviderTemplatesById, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getSpeechModels, getSpeechModelsById, getSpeechModelsByIdCapabilities, getSpeechProviders, getSpeechProvidersById, getSpeechProvidersByIdModels, getSpeechProvidersMeta, getSupermarketArtifactsIconByDigest, getSupermarketPackages, getSupermarketRegistries, getSupermarketRegistriesByRegistryIdCategories, getSupermarketRegistriesByRegistryIdPackages, getSupermarketRegistriesByRegistryIdPackagesByPackageId, getSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevision, getSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillId, getSupermarketSkills, getTranscriptionModels, getTranscriptionModelsById, getTranscriptionModelsByIdCapabilities, getTranscriptionProviders, getTranscriptionProvidersById, getTranscriptionProvidersByIdModels, getTranscriptionProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelIdentities, getUsersMeChannelsByPlatform, getUsersMeComputerAccess, getUsersMeRuntimes, getVideoModels, getVideoModelsById, getVideoProviders, getVideoProvidersById, getVideoProvidersByIdModels, getVideoProvidersMeta, getWebhookTunnelStatus, type Options, patchBotsByBotIdAcpRuntimesByRuntimeIdMode, patchBotsByBotIdAcpRuntimesByRuntimeIdModel, patchBotsByBotIdAcpRuntimesByRuntimeIdReasoning, patchBotsByBotIdAgentsById, patchBotsByBotIdConnectorsByConnectionId, patchBotsByBotIdSessionsBySessionId, patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode, patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel, patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning, patchBotsByBotIdWorkdirsByWorkdirId, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postAuthRefresh, postBots, postBotsBackupImport, postBotsBackupImportPreview, postBotsByBotIdAclRules, postBotsByBotIdAcpRuntimes, postBotsByBotIdAgents, postBotsByBotIdAgentsByIdCodexLoginDeviceAuthorize, postBotsByBotIdAgentsByIdCodexLoginDeviceCancel, postBotsByBotIdAgentsByIdCodexLoginDevicePoll, postBotsByBotIdBackupExport, postBotsByBotIdChannelManagers, postBotsByBotIdConnectorsApiKey, postBotsByBotIdConnectorsByConnectionIdReauth, postBotsByBotIdConnectorsOauth, postBotsByBotIdContainer, postBotsByBotIdContainerBrowserSessions, postBotsByBotIdContainerBrowserSessionsBySessionIdKeepalive, postBotsByBotIdContainerDataRestore, postBotsByBotIdContainerDisplayPrepare, postBotsByBotIdContainerDisplayWebrtcOffer, postBotsByBotIdContainerFsArchive, postBotsByBotIdContainerFsDelete, postBotsByBotIdContainerFsExtract, postBotsByBotIdContainerFsMkdir, postBotsByBotIdContainerFsRename, postBotsByBotIdContainerFsUpload, postBotsByBotIdContainerFsWrite, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSkillsActions, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshotsRollback, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdEmailBindings, postBotsByBotIdHooksTest, postBotsByBotIdMcp, postBotsByBotIdMcpByIdOauthAuthorize, postBotsByBotIdMcpByIdOauthDiscover, postBotsByBotIdMcpByIdOauthExchange, postBotsByBotIdMcpByIdProbe, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryIngest, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdQuickActionsExecute, postBotsByBotIdSchedule, postBotsByBotIdSessions, postBotsByBotIdSessionsBySessionIdAcpRuntime, postBotsByBotIdSessionsBySessionIdCompact, postBotsByBotIdSessionsBySessionIdFork, postBotsByBotIdSettings, postBotsByBotIdSupermarketInstallPackage, postBotsByBotIdToolApprovalsByApprovalIdApprove, postBotsByBotIdToolApprovalsByApprovalIdReject, postBotsByBotIdTools, postBotsByBotIdTtsSynthesize, postBotsByBotIdUserAccess, postBotsByBotIdWebMessages, postBotsByBotIdWorkdirs, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postBotsByIdChannelByPlatformWebhookEndpoint, postEmailMailgunWebhookByConfigId, postEmailProviders, postFetchProviders, postMemoryProviders, postModels, postModelsByIdTest, postProviders, postProvidersByIdImportModels, postProvidersByIdOauthPoll, postProvidersByIdTest, postProvidersFromTemplate, postSearchProviders, postSpeechModelsByIdTest, postSpeechProvidersByIdImportModels, postTranscriptionModelsByIdTest, postTranscriptionProvidersByIdImportModels, postUsers, postUsersMeChannelLinks, postUsersMeRuntimes, postVideoProvidersByIdImportModels, putBotsByBotIdAclDefaultEffect, putBotsByBotIdAclRulesByRuleId, putBotsByBotIdAgentsByIdCredential, putBotsByBotIdContainerMetrics, putBotsByBotIdEmailBindingsById, putBotsByBotIdMcpById, putBotsByBotIdMcpOpsImport, putBotsByBotIdMemoryByMemoryId, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdUserAccessByGrantId, putBotsByBotIdWorkspaceTargetsByTargetIdToolApproval, putBotsByBotIdWorkspaceTargetsPrimary, putBotsByBotIdWorkspaceTargetsRemotesByRuntimeId, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdOwner, putEmailProvidersById, putFetchProvidersById, putMemoryProvidersById, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putSpeechModelsById, putTranscriptionModelsById, putUsersById, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword, putVideoModelsById } from './sdk.gen'; -export type { AccountsAccount, AccountsCreateAccountRequest, AccountsListAccountsResponse, AccountsUpdateAccountRequest, AccountsUpdatePasswordRequest, AccountsUpdateProfileMetadata, AccountsUpdateProfileRequest, AclChannelIdentityCandidate, AclChannelIdentityCandidateListResponse, AclCreateRuleRequest, AclDefaultEffectResponse, AclListRulesResponse, AclObservedConversationCandidate, AclObservedConversationCandidateListResponse, AclRule, AclSourceScope, AclUpdateRuleRequest, AcpagentRuntimeStatus, AcpclientAvailableCommandInfo, AcpclientModeInfo, AcpclientModelInfo, AcpclientModelState, AcpclientModeState, AcpclientReasoningEffortInfo, AcpclientReasoningState, AcpprofileManagedField, AcpprofileProfilesResponse, AcpprofilePublicProfile, AdaptersCompactResult, AdaptersDeleteResponse, AdaptersHealthStatus, AdaptersIngestResult, AdaptersMemoryCompactCapability, AdaptersMemoryItem, AdaptersMemoryStatusResponse, AdaptersMessage, AdaptersProviderCollectionStatus, AdaptersProviderConfigSchema, AdaptersProviderCreateRequest, AdaptersProviderFieldSchema, AdaptersProviderGetResponse, AdaptersProviderMeta, AdaptersProviderStatusResponse, AdaptersProviderType, AdaptersProviderUpdateRequest, AdaptersRebuildResult, AdaptersSearchResponse, AdaptersUsageResponse, AgentcredentialPublicCredential, ApperrorProblem, AudioConfigSchema, AudioFieldSchema, AudioImportModelsResponse, AudioModelCapabilities, AudioModelInfo, AudioParamConstraint, AudioProviderMetaResponse, AudioSpeechModelResponse, AudioSpeechProviderResponse, AudioTestSynthesizeRequest, AudioTestTranscriptionResponse, AudioTranscriptionModelResponse, AudioTranscriptionWord, AudioUpdateSpeechModelRequest, AudioVoiceInfo, BotagentsBotAgent, BotagentsCreateRequest, BotagentsListResponse, BotagentsUpdateRequest, BotbackupExportRequest, BotbackupImportMode, BotbackupImportResult, BotbackupManifest, BotbackupManifestEntry, BotbackupManifestOptions, BotbackupPreviewResult, BotbackupProfilePreview, BotbackupRestorePlan, BotbackupSection, BotbackupSectionSummary, BotbackupSummaryResult, BotsBot, BotsBotCheck, BotsCreateBotRequest, BotsCreateUserGrantRequest, BotsListBotsResponse, BotsListChecksResponse, BotsNameAvailability, BotsTransferBotRequest, BotsUpdateBotRequest, BotsUpdateUserGrantRequest, BotsUserGrant, ChannelaccessBinding, ChannelaccessIssueLinkCodeRequest, ChannelaccessLinkCode, ChannelaccessListBindingsResponse, ChannelaccessListManagersResponse, ChannelaccessManager, ChannelaccessSetManagerRequest, ChannelAction, ChannelAttachment, ChannelAttachmentType, ChannelChannelCapabilities, ChannelChannelConfig, ChannelChannelIdentityBinding, ChannelChannelType, ChannelConfigSchema, ChannelFieldSchema, ChannelFieldType, ChannelForwardRef, ChannelMessage, ChannelMessageFormat, ChannelMessagePart, ChannelMessagePartType, ChannelMessageTextStyle, ChannelReplyRef, ChannelSendRequest, ChannelSetWebhookEndpointRequest, ChannelSetWebhookEndpointResponse, ChannelTargetHint, ChannelTargetSpec, ChannelThreadRef, ChannelUpdateChannelStatusRequest, ChannelUpsertChannelIdentityConfigRequest, ChannelUpsertConfigRequest, ClientOptions, CompactionListLogsResponse, CompactionLog, ConnectitAuthMethod, ConnectitConnector, ConnectitCredentialField, ConnectitOAuthAuthorization, ConnectorsConnector, ConnectorsListResponse, ContextfragCacheClass, ContextfragCacheComparison, ContextfragCacheUsageRecord, ContextfragContentRange, ContextfragContextBudgetPlan, ContextfragContextRef, ContextfragKind, ContextfragKindBreakdown, ContextfragLifecycleSnapshot, ContextfragManifestCounts, ContextfragManifestView, ContextfragMemoryRecallQueryTrace, ContextfragMemoryRecallResultTrace, ContextfragMemoryRecallTrace, ContextfragMutationKind, ContextfragMutationRecord, ContextfragRefDurability, ContextfragRetentionTier, ContextfragSelectionDecision, ContextfragSelectionDecisionKind, ContextfragSelectionTrace, ContextfragSlot, ContextfragStepSnapshot, ContextfragToolDefAccounting, ContextfragTrustBreakdown, ContextfragTrustLevel, ConversationSkillActivation, ConversationSkillActivationSkill, ConversationUiAttachment, ConversationUiBackgroundTask, ConversationUiExecutionLocation, ConversationUiForwardRef, ConversationUiMessage, ConversationUiMessageType, ConversationUiReasoningTiming, ConversationUiReplyRef, ConversationUiToolApproval, ConversationUiToolApprovalOption, ConversationUiTurn, ConversationUiUserInput, DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdError, DeleteBotsByBotIdAclRulesByRuleIdErrors, DeleteBotsByBotIdAclRulesByRuleIdResponses, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdError, DeleteBotsByBotIdAcpRuntimesByRuntimeIdErrors, DeleteBotsByBotIdAcpRuntimesByRuntimeIdResponses, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialError, DeleteBotsByBotIdAgentsByIdCredentialErrors, DeleteBotsByBotIdAgentsByIdCredentialResponses, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdError, DeleteBotsByBotIdAgentsByIdErrors, DeleteBotsByBotIdAgentsByIdResponses, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdError, DeleteBotsByBotIdChannelManagersByChannelIdentityIdErrors, DeleteBotsByBotIdChannelManagersByChannelIdentityIdResponses, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsError, DeleteBotsByBotIdCompactionLogsErrors, DeleteBotsByBotIdCompactionLogsResponses, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdError, DeleteBotsByBotIdConnectorsByConnectionIdErrors, DeleteBotsByBotIdConnectorsByConnectionIdResponses, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdError, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdErrors, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdResponses, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdError, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdErrors, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdResponses, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdError, DeleteBotsByBotIdEmailBindingsByIdErrors, DeleteBotsByBotIdEmailBindingsByIdResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenError, DeleteBotsByBotIdMcpByIdOauthTokenErrors, DeleteBotsByBotIdMcpByIdOauthTokenResponses, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesError, DeleteBotsByBotIdMessagesErrors, DeleteBotsByBotIdMessagesResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsError, DeleteBotsByBotIdScheduleLogsErrors, DeleteBotsByBotIdScheduleLogsResponses, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdError, DeleteBotsByBotIdSessionsBySessionIdErrors, DeleteBotsByBotIdSessionsBySessionIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdErrors, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponse, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponses, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdError, DeleteBotsByBotIdUserAccessByGrantIdErrors, DeleteBotsByBotIdUserAccessByGrantIdResponses, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdError, DeleteBotsByBotIdWorkdirsByWorkdirIdErrors, DeleteBotsByBotIdWorkdirsByWorkdirIdResponses, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdError, DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors, DeleteBotsByBotIdWorkspaceTargetsByTargetIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdErrors, DeleteBotsByIdResponse, DeleteBotsByIdResponses, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdError, DeleteEmailProvidersByIdErrors, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenError, DeleteEmailProvidersByIdOauthTokenErrors, DeleteEmailProvidersByIdOauthTokenResponses, DeleteEmailProvidersByIdResponses, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdError, DeleteFetchProvidersByIdErrors, DeleteFetchProvidersByIdResponses, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdError, DeleteMemoryProvidersByIdErrors, DeleteMemoryProvidersByIdResponses, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdErrors, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenError, DeleteProvidersByIdOauthTokenErrors, DeleteProvidersByIdOauthTokenResponses, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, DeleteUsersByIdData, DeleteUsersByIdError, DeleteUsersByIdErrors, DeleteUsersByIdResponses, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdError, DeleteUsersMeChannelIdentitiesByChannelIdentityIdErrors, DeleteUsersMeChannelIdentitiesByChannelIdentityIdResponses, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdError, DeleteUsersMeRuntimesByIdErrors, DeleteUsersMeRuntimesByIdResponses, DisplaySessionInfo, EmailBindingResponse, EmailConfigSchema, EmailCreateBindingRequest, EmailCreateProviderRequest, EmailFieldSchema, EmailOutboxItemResponse, EmailProviderMeta, EmailProviderResponse, EmailUpdateBindingRequest, EmailUpdateProviderRequest, ExternalagentCodexDeviceLoginAuthorizeResponse, ExternalagentCodexDeviceLoginPollRequest, ExternalagentCodexDeviceLoginPollResponse, ExternalModelCatalog, ExternalModelOption, ExternalReasoningEffortOption, FetchprovidersCreateRequest, FetchprovidersGetResponse, FetchprovidersProviderConfigSchema, FetchprovidersProviderFieldSchema, FetchprovidersProviderMeta, FetchprovidersProviderName, FetchprovidersUpdateRequest, GetAcpProfilesData, GetAcpProfilesResponse, GetAcpProfilesResponses, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsError, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponse, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesError, GetBotsByBotIdAclChannelIdentitiesErrors, GetBotsByBotIdAclChannelIdentitiesResponse, GetBotsByBotIdAclChannelIdentitiesResponses, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsError, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponse, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectError, GetBotsByBotIdAclDefaultEffectErrors, GetBotsByBotIdAclDefaultEffectResponse, GetBotsByBotIdAclDefaultEffectResponses, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesError, GetBotsByBotIdAclRulesErrors, GetBotsByBotIdAclRulesResponse, GetBotsByBotIdAclRulesResponses, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdError, GetBotsByBotIdAcpRuntimesByRuntimeIdErrors, GetBotsByBotIdAcpRuntimesByRuntimeIdResponse, GetBotsByBotIdAcpRuntimesByRuntimeIdResponses, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialError, GetBotsByBotIdAgentsByIdCredentialErrors, GetBotsByBotIdAgentsByIdCredentialResponse, GetBotsByBotIdAgentsByIdCredentialResponses, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdError, GetBotsByBotIdAgentsByIdErrors, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsError, GetBotsByBotIdAgentsByIdModelsErrors, GetBotsByBotIdAgentsByIdModelsResponse, GetBotsByBotIdAgentsByIdModelsResponses, GetBotsByBotIdAgentsByIdResponse, GetBotsByBotIdAgentsByIdResponses, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsError, GetBotsByBotIdAgentsErrors, GetBotsByBotIdAgentsResponse, GetBotsByBotIdAgentsResponses, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryError, GetBotsByBotIdBackupSummaryErrors, GetBotsByBotIdBackupSummaryResponse, GetBotsByBotIdBackupSummaryResponses, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersError, GetBotsByBotIdChannelManagersErrors, GetBotsByBotIdChannelManagersResponse, GetBotsByBotIdChannelManagersResponses, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsError, GetBotsByBotIdCompactionLogsErrors, GetBotsByBotIdCompactionLogsResponse, GetBotsByBotIdCompactionLogsResponses, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdError, GetBotsByBotIdConnectorsByConnectionIdErrors, GetBotsByBotIdConnectorsByConnectionIdResponse, GetBotsByBotIdConnectorsByConnectionIdResponses, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsError, GetBotsByBotIdConnectorsErrors, GetBotsByBotIdConnectorsResponse, GetBotsByBotIdConnectorsResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayError, GetBotsByBotIdContainerDisplayErrors, GetBotsByBotIdContainerDisplayResponse, GetBotsByBotIdContainerDisplayResponses, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsError, GetBotsByBotIdContainerDisplaySessionsErrors, GetBotsByBotIdContainerDisplaySessionsResponse, GetBotsByBotIdContainerDisplaySessionsResponses, GetBotsByBotIdContainerError, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadError, GetBotsByBotIdContainerFsDownloadErrors, GetBotsByBotIdContainerFsDownloadResponses, GetBotsByBotIdContainerFsError, GetBotsByBotIdContainerFsErrors, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListError, GetBotsByBotIdContainerFsListErrors, GetBotsByBotIdContainerFsListResponse, GetBotsByBotIdContainerFsListResponses, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadError, GetBotsByBotIdContainerFsReadErrors, GetBotsByBotIdContainerFsReadResponse, GetBotsByBotIdContainerFsReadResponses, GetBotsByBotIdContainerFsResponse, GetBotsByBotIdContainerFsResponses, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsError, GetBotsByBotIdContainerMetricsErrors, GetBotsByBotIdContainerMetricsResponse, GetBotsByBotIdContainerMetricsResponses, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsError, GetBotsByBotIdContainerSnapshotsErrors, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalError, GetBotsByBotIdContainerTerminalErrors, GetBotsByBotIdContainerTerminalResponse, GetBotsByBotIdContainerTerminalResponses, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsError, GetBotsByBotIdContainerTerminalWsErrors, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsError, GetBotsByBotIdEmailBindingsErrors, GetBotsByBotIdEmailBindingsResponse, GetBotsByBotIdEmailBindingsResponses, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdError, GetBotsByBotIdEmailOutboxByIdErrors, GetBotsByBotIdEmailOutboxByIdResponse, GetBotsByBotIdEmailOutboxByIdResponses, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxError, GetBotsByBotIdEmailOutboxErrors, GetBotsByBotIdEmailOutboxResponse, GetBotsByBotIdEmailOutboxResponses, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsError, GetBotsByBotIdHooksEventsErrors, GetBotsByBotIdHooksEventsResponse, GetBotsByBotIdHooksEventsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusError, GetBotsByBotIdMcpByIdOauthStatusErrors, GetBotsByBotIdMcpByIdOauthStatusResponse, GetBotsByBotIdMcpByIdOauthStatusResponses, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportError, GetBotsByBotIdMcpOpsExportErrors, GetBotsByBotIdMcpOpsExportResponse, GetBotsByBotIdMcpOpsExportResponses, GetBotsByBotIdMcpResponse, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphError, GetBotsByBotIdMemoryGraphErrors, GetBotsByBotIdMemoryGraphResponse, GetBotsByBotIdMemoryGraphResponses, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusError, GetBotsByBotIdMemoryStatusErrors, GetBotsByBotIdMemoryStatusResponse, GetBotsByBotIdMemoryStatusResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateError, GetBotsByBotIdMessagesLocateErrors, GetBotsByBotIdMessagesLocateResponse, GetBotsByBotIdMessagesLocateResponses, GetBotsByBotIdMessagesResponse, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsError, GetBotsByBotIdScheduleByIdLogsErrors, GetBotsByBotIdScheduleByIdLogsResponse, GetBotsByBotIdScheduleByIdLogsResponses, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsError, GetBotsByBotIdScheduleLogsErrors, GetBotsByBotIdScheduleLogsResponse, GetBotsByBotIdScheduleLogsResponses, GetBotsByBotIdScheduleResponse, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeError, GetBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleError, GetBotsByBotIdSessionsBySessionIdContextLifecycleErrors, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponse, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdError, GetBotsByBotIdSessionsBySessionIdErrors, GetBotsByBotIdSessionsBySessionIdResponse, GetBotsByBotIdSessionsBySessionIdResponses, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusError, GetBotsByBotIdSessionsBySessionIdStatusErrors, GetBotsByBotIdSessionsBySessionIdStatusResponse, GetBotsByBotIdSessionsBySessionIdStatusResponses, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsError, GetBotsByBotIdSessionsErrors, GetBotsByBotIdSessionsEventsData, GetBotsByBotIdSessionsEventsError, GetBotsByBotIdSessionsEventsErrors, GetBotsByBotIdSessionsEventsResponse, GetBotsByBotIdSessionsEventsResponses, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedError, GetBotsByBotIdSessionsModelPreferenceSeedErrors, GetBotsByBotIdSessionsModelPreferenceSeedResponse, GetBotsByBotIdSessionsModelPreferenceSeedResponses, GetBotsByBotIdSessionsResponse, GetBotsByBotIdSessionsResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogError, GetBotsByBotIdSkillsCatalogErrors, GetBotsByBotIdSkillsCatalogResponse, GetBotsByBotIdSkillsCatalogResponses, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesError, GetBotsByBotIdSupermarketPackagesErrors, GetBotsByBotIdSupermarketPackagesResponse, GetBotsByBotIdSupermarketPackagesResponses, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageError, GetBotsByBotIdTokenUsageErrors, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsError, GetBotsByBotIdTokenUsageRecordsErrors, GetBotsByBotIdTokenUsageRecordsResponse, GetBotsByBotIdTokenUsageRecordsResponses, GetBotsByBotIdTokenUsageResponse, GetBotsByBotIdTokenUsageResponses, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesError, GetBotsByBotIdUserAccessCandidatesErrors, GetBotsByBotIdUserAccessCandidatesResponse, GetBotsByBotIdUserAccessCandidatesResponses, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessError, GetBotsByBotIdUserAccessErrors, GetBotsByBotIdUserAccessResponse, GetBotsByBotIdUserAccessResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamError, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponse, GetBotsByBotIdWebStreamResponses, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsError, GetBotsByBotIdWebWsErrors, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsError, GetBotsByBotIdWorkdirsErrors, GetBotsByBotIdWorkdirsResponse, GetBotsByBotIdWorkdirsResponses, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsError, GetBotsByBotIdWorkspaceTargetsErrors, GetBotsByBotIdWorkspaceTargetsResponse, GetBotsByBotIdWorkspaceTargetsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksErrors, GetBotsByIdChecksResponse, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdError, GetBotsByIdErrors, GetBotsByIdResponse, GetBotsByIdResponses, GetBotsData, GetBotsError, GetBotsErrors, GetBotsNameAvailabilityData, GetBotsNameAvailabilityError, GetBotsNameAvailabilityErrors, GetBotsNameAvailabilityResponse, GetBotsNameAvailabilityResponses, GetBotsResponse, GetBotsResponses, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesError, GetBotsUserAccessCandidatesErrors, GetBotsUserAccessCandidatesResponse, GetBotsUserAccessCandidatesResponses, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformErrors, GetChannelsByPlatformResponse, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetConnectorsCatalogData, GetConnectorsCatalogError, GetConnectorsCatalogErrors, GetConnectorsCatalogResponse, GetConnectorsCatalogResponses, GetEmailOauthCallbackData, GetEmailOauthCallbackError, GetEmailOauthCallbackErrors, GetEmailOauthCallbackResponse, GetEmailOauthCallbackResponses, GetEmailProvidersByIdData, GetEmailProvidersByIdError, GetEmailProvidersByIdErrors, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeError, GetEmailProvidersByIdOauthAuthorizeErrors, GetEmailProvidersByIdOauthAuthorizeResponse, GetEmailProvidersByIdOauthAuthorizeResponses, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusError, GetEmailProvidersByIdOauthStatusErrors, GetEmailProvidersByIdOauthStatusResponse, GetEmailProvidersByIdOauthStatusResponses, GetEmailProvidersByIdResponse, GetEmailProvidersByIdResponses, GetEmailProvidersData, GetEmailProvidersError, GetEmailProvidersErrors, GetEmailProvidersMetaData, GetEmailProvidersMetaResponse, GetEmailProvidersMetaResponses, GetEmailProvidersResponse, GetEmailProvidersResponses, GetFetchProvidersByIdData, GetFetchProvidersByIdError, GetFetchProvidersByIdErrors, GetFetchProvidersByIdResponse, GetFetchProvidersByIdResponses, GetFetchProvidersData, GetFetchProvidersError, GetFetchProvidersErrors, GetFetchProvidersMetaData, GetFetchProvidersMetaResponse, GetFetchProvidersMetaResponses, GetFetchProvidersResponse, GetFetchProvidersResponses, GetMemoryProvidersByIdData, GetMemoryProvidersByIdError, GetMemoryProvidersByIdErrors, GetMemoryProvidersByIdResponse, GetMemoryProvidersByIdResponses, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusError, GetMemoryProvidersByIdStatusErrors, GetMemoryProvidersByIdStatusResponse, GetMemoryProvidersByIdStatusResponses, GetMemoryProvidersData, GetMemoryProvidersError, GetMemoryProvidersErrors, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponse, GetMemoryProvidersMetaResponses, GetMemoryProvidersResponse, GetMemoryProvidersResponses, GetModelsByIdData, GetModelsByIdError, GetModelsByIdErrors, GetModelsByIdResponse, GetModelsByIdResponses, GetModelsCountData, GetModelsCountError, GetModelsCountErrors, GetModelsCountResponse, GetModelsCountResponses, GetModelsData, GetModelsError, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponse, GetModelsModelByModelIdResponses, GetModelsResponse, GetModelsResponses, GetOauthMcpCallbackData, GetOauthMcpCallbackError, GetOauthMcpCallbackErrors, GetOauthMcpCallbackResponse, GetOauthMcpCallbackResponses, GetPingData, GetPingResponse, GetPingResponses, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponse, GetProvidersByIdModelsResponses, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeError, GetProvidersByIdOauthAuthorizeErrors, GetProvidersByIdOauthAuthorizeResponse, GetProvidersByIdOauthAuthorizeResponses, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusError, GetProvidersByIdOauthStatusErrors, GetProvidersByIdOauthStatusResponse, GetProvidersByIdOauthStatusResponses, GetProvidersByIdResponse, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountError, GetProvidersCountErrors, GetProvidersCountResponse, GetProvidersCountResponses, GetProvidersData, GetProvidersError, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameErrors, GetProvidersNameByNameResponse, GetProvidersNameByNameResponses, GetProvidersOauthCallbackData, GetProvidersOauthCallbackError, GetProvidersOauthCallbackErrors, GetProvidersOauthCallbackResponse, GetProvidersOauthCallbackResponses, GetProvidersResponse, GetProvidersResponses, GetProviderTemplatesByIdData, GetProviderTemplatesByIdError, GetProviderTemplatesByIdErrors, GetProviderTemplatesByIdResponse, GetProviderTemplatesByIdResponses, GetProviderTemplatesData, GetProviderTemplatesError, GetProviderTemplatesErrors, GetProviderTemplatesResponse, GetProviderTemplatesResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponse, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersMetaResponses, GetSearchProvidersResponse, GetSearchProvidersResponses, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesError, GetSpeechModelsByIdCapabilitiesErrors, GetSpeechModelsByIdCapabilitiesResponse, GetSpeechModelsByIdCapabilitiesResponses, GetSpeechModelsByIdData, GetSpeechModelsByIdError, GetSpeechModelsByIdErrors, GetSpeechModelsByIdResponse, GetSpeechModelsByIdResponses, GetSpeechModelsData, GetSpeechModelsError, GetSpeechModelsErrors, GetSpeechModelsResponse, GetSpeechModelsResponses, GetSpeechProvidersByIdData, GetSpeechProvidersByIdError, GetSpeechProvidersByIdErrors, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsError, GetSpeechProvidersByIdModelsErrors, GetSpeechProvidersByIdModelsResponse, GetSpeechProvidersByIdModelsResponses, GetSpeechProvidersByIdResponse, GetSpeechProvidersByIdResponses, GetSpeechProvidersData, GetSpeechProvidersError, GetSpeechProvidersErrors, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponse, GetSpeechProvidersMetaResponses, GetSpeechProvidersResponse, GetSpeechProvidersResponses, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestError, GetSupermarketArtifactsIconByDigestErrors, GetSupermarketArtifactsIconByDigestResponses, GetSupermarketPackagesData, GetSupermarketPackagesError, GetSupermarketPackagesErrors, GetSupermarketPackagesResponse, GetSupermarketPackagesResponses, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesError, GetSupermarketRegistriesByRegistryIdCategoriesErrors, GetSupermarketRegistriesByRegistryIdCategoriesResponse, GetSupermarketRegistriesByRegistryIdCategoriesResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponses, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesError, GetSupermarketRegistriesByRegistryIdPackagesErrors, GetSupermarketRegistriesByRegistryIdPackagesResponse, GetSupermarketRegistriesByRegistryIdPackagesResponses, GetSupermarketRegistriesData, GetSupermarketRegistriesError, GetSupermarketRegistriesErrors, GetSupermarketRegistriesResponse, GetSupermarketRegistriesResponses, GetSupermarketSkillsData, GetSupermarketSkillsError, GetSupermarketSkillsErrors, GetSupermarketSkillsResponse, GetSupermarketSkillsResponses, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesError, GetTranscriptionModelsByIdCapabilitiesErrors, GetTranscriptionModelsByIdCapabilitiesResponse, GetTranscriptionModelsByIdCapabilitiesResponses, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdError, GetTranscriptionModelsByIdErrors, GetTranscriptionModelsByIdResponse, GetTranscriptionModelsByIdResponses, GetTranscriptionModelsData, GetTranscriptionModelsError, GetTranscriptionModelsErrors, GetTranscriptionModelsResponse, GetTranscriptionModelsResponses, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdError, GetTranscriptionProvidersByIdErrors, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsError, GetTranscriptionProvidersByIdModelsErrors, GetTranscriptionProvidersByIdModelsResponse, GetTranscriptionProvidersByIdModelsResponses, GetTranscriptionProvidersByIdResponse, GetTranscriptionProvidersByIdResponses, GetTranscriptionProvidersData, GetTranscriptionProvidersError, GetTranscriptionProvidersErrors, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponse, GetTranscriptionProvidersMetaResponses, GetTranscriptionProvidersResponse, GetTranscriptionProvidersResponses, GetUsersByIdData, GetUsersByIdError, GetUsersByIdErrors, GetUsersByIdResponse, GetUsersByIdResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesError, GetUsersMeChannelIdentitiesErrors, GetUsersMeChannelIdentitiesResponse, GetUsersMeChannelIdentitiesResponses, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponse, GetUsersMeChannelsByPlatformResponses, GetUsersMeComputerAccessData, GetUsersMeComputerAccessError, GetUsersMeComputerAccessErrors, GetUsersMeComputerAccessResponse, GetUsersMeComputerAccessResponses, GetUsersMeData, GetUsersMeError, GetUsersMeErrors, GetUsersMeResponse, GetUsersMeResponses, GetUsersMeRuntimesData, GetUsersMeRuntimesError, GetUsersMeRuntimesErrors, GetUsersMeRuntimesResponse, GetUsersMeRuntimesResponses, GetUsersResponse, GetUsersResponses, GetVideoModelsByIdData, GetVideoModelsByIdError, GetVideoModelsByIdErrors, GetVideoModelsByIdResponse, GetVideoModelsByIdResponses, GetVideoModelsData, GetVideoModelsError, GetVideoModelsErrors, GetVideoModelsResponse, GetVideoModelsResponses, GetVideoProvidersByIdData, GetVideoProvidersByIdError, GetVideoProvidersByIdErrors, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsError, GetVideoProvidersByIdModelsErrors, GetVideoProvidersByIdModelsResponse, GetVideoProvidersByIdModelsResponses, GetVideoProvidersByIdResponse, GetVideoProvidersByIdResponses, GetVideoProvidersData, GetVideoProvidersError, GetVideoProvidersErrors, GetVideoProvidersMetaData, GetVideoProvidersMetaResponse, GetVideoProvidersMetaResponses, GetVideoProvidersResponse, GetVideoProvidersResponses, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponse, GetWebhookTunnelStatusResponses, GithubComFelinicsMemohInternalMcpConnection, HandlersAcpRuntimeCreateRequest, HandlersAcpRuntimeModelRequest, HandlersAcpRuntimeModeRequest, HandlersAcpRuntimeReasoningRequest, HandlersAgentCredentialPutRequest, HandlersBatchDeleteRequest, HandlersBotUserCandidate, HandlersBotUserCandidateListResponse, HandlersBotUserGrantListResponse, HandlersBrowserSessionCreateRequest, HandlersBrowserSessionCreateResponse, HandlersBrowserSessionKeepAliveResponse, HandlersCacheStats, HandlersChannelMeta, HandlersCommandActionError, HandlersCommandActionListItem, HandlersCommandActionResult, HandlersCommandEventResponse, HandlersCompactionInfo, HandlersConnectorCredentialRequest, HandlersConnectorEnabledRequest, HandlersConnectorOAuthRequest, HandlersContainerCpuMetricsResponse, HandlersContainerGpuRequest, HandlersContainerMemoryMetricsResponse, HandlersContainerMetricsPayloadResponse, HandlersContainerMetricsStatusResponse, HandlersContainerResourceLimitCapabilitiesResponse, HandlersContainerResourceLimitCapabilityResponse, HandlersContainerResourceLimitObservedResponse, HandlersContainerResourceLimitValuesResponse, HandlersContainerStorageMetricsResponse, HandlersContextLifecycleAggregates, HandlersContextLifecycleResponse, HandlersContextLifecycleTurn, HandlersContextUsage, HandlersCreateContainerRequest, HandlersCreateContainerResponse, HandlersCreateSessionRequest, HandlersCreateSnapshotRequest, HandlersCreateSnapshotResponse, HandlersDailyTokenUsage, HandlersDisplayInfoResponse, HandlersDisplaySessionListResponse, HandlersDisplayWebRtcOfferRequest, HandlersDisplayWebRtcOfferResponse, HandlersEmailOAuthStatusResponse, HandlersErrorResponse, HandlersForkSessionRequest, HandlersFsArchiveRequest, HandlersFsDeleteRequest, HandlersFsExtractRequest, HandlersFsExtractResponse, HandlersFsFileInfo, HandlersFsListResponse, HandlersFsMkdirRequest, HandlersFsOpResponse, HandlersFsReadResponse, HandlersFsRenameRequest, HandlersFsUploadResponse, HandlersFsWriteRequest, HandlersGetContainerMetricsResponse, HandlersGetContainerResourceLimitsResponse, HandlersGetContainerResponse, HandlersGraphEdge, HandlersGraphNode, HandlersGraphResponse, HandlersHookEventInfo, HandlersHooksEventsResponse, HandlersHookTestRequest, HandlersHookTestResponse, HandlersInstallPackageRequest, HandlersInstallRegistryPackageResponse, HandlersInstallRegistrySkillResponse, HandlersListSessionsResponse, HandlersListSnapshotsResponse, HandlersLocalChannelMessageRequest, HandlersLoginRequest, HandlersLoginResponse, HandlersMcpStdioRequest, HandlersMcpStdioResponse, HandlersMemoryAddPayload, HandlersMemoryCompactPayload, HandlersMemoryDeletePayload, HandlersMemorySearchPayload, HandlersMemoryUpdatePayload, HandlersModelPreferenceSeedResponse, HandlersModelTokenUsage, HandlersOauthAuthorizeRequest, HandlersOauthDiscoverRequest, HandlersOauthExchangeRequest, HandlersPingResponse, HandlersProbeResponse, HandlersQuickActionExecuteRequest, HandlersRefreshResponse, HandlersRollbackRequest, HandlersSafeSkillsResponse, HandlersSessionInfoResponse, HandlersSkillItem, HandlersSkillsActionRequest, HandlersSkillsDeleteRequest, HandlersSkillsOpResponse, HandlersSkillsResponse, HandlersSkillsUpsertRequest, HandlersSnapshotInfo, HandlersSupermarketAuthor, HandlersSupermarketCatalogSkill, HandlersSupermarketCatalogSkillListResponse, HandlersSupermarketRegistry, HandlersSupermarketRegistryListResponse, HandlersSupermarketSkillArtifact, HandlersSupermarketSkillCategory, HandlersSupermarketSkillCategoryListResponse, HandlersSupermarketSkillCategoryRegistry, HandlersSupermarketSkillIcon, HandlersSupermarketSkillIconAsset, HandlersSupermarketSkillPackageCategory, HandlersSupermarketSkillPackageDescriptor, HandlersSupermarketSkillPackageListResponse, HandlersSupermarketSkillPackageSummary, HandlersSupermarketSkillSource, HandlersSynthesizeRequest, HandlersSynthesizeResponse, HandlersTerminalInfoResponse, HandlersTokenUsageRecord, HandlersTokenUsageRecordsResponse, HandlersTokenUsageResponse, HandlersToolApprovalDecisionRequest, HandlersToolDefBucket, HandlersTriggerCompactResponse, HandlersUiLocateMessageResponse, HandlersUiMessageListResponse, HandlersUpdateContainerMetricsRequest, HandlersUpdateContainerResourceLimitsRequest, HandlersUpdateSessionRequest, HooksActionResult, HooksOutputWarning, HooksResult, HooksSystemSectionCache, HooksSystemSectionOutput, HooksSystemSectionRetention, HooksToolPayload, McpAuthorizeResult, McpDiscoveryResult, McpExportResponse, McpImportRequest, McpListResponse, McpMcpServerEntry, McpOAuthStatus, McpToolDescriptor, McpUpsertRequest, ModelsAddRequest, ModelsAddResponse, ModelsCountResponse, ModelsGetResponse, ModelsModelConfig, ModelsModelType, ModelsTestResponse, ModelsTestStatus, ModelsUpdateRequest, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningError, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponses, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdError, PatchBotsByBotIdAgentsByIdErrors, PatchBotsByBotIdAgentsByIdResponse, PatchBotsByBotIdAgentsByIdResponses, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdError, PatchBotsByBotIdConnectorsByConnectionIdErrors, PatchBotsByBotIdConnectorsByConnectionIdResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponses, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdError, PatchBotsByBotIdSessionsBySessionIdErrors, PatchBotsByBotIdSessionsBySessionIdResponse, PatchBotsByBotIdSessionsBySessionIdResponses, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdError, PatchBotsByBotIdWorkdirsByWorkdirIdErrors, PatchBotsByBotIdWorkdirsByWorkdirIdResponse, PatchBotsByBotIdWorkdirsByWorkdirIdResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponse, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostBotsBackupImportData, PostBotsBackupImportError, PostBotsBackupImportErrors, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewError, PostBotsBackupImportPreviewErrors, PostBotsBackupImportPreviewResponse, PostBotsBackupImportPreviewResponses, PostBotsBackupImportResponse, PostBotsBackupImportResponses, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesError, PostBotsByBotIdAclRulesErrors, PostBotsByBotIdAclRulesResponse, PostBotsByBotIdAclRulesResponses, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesError, PostBotsByBotIdAcpRuntimesErrors, PostBotsByBotIdAcpRuntimesResponse, PostBotsByBotIdAcpRuntimesResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeError, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelError, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelResponses, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollErrors, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponse, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponses, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsError, PostBotsByBotIdAgentsErrors, PostBotsByBotIdAgentsResponse, PostBotsByBotIdAgentsResponses, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportError, PostBotsByBotIdBackupExportErrors, PostBotsByBotIdBackupExportResponses, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersError, PostBotsByBotIdChannelManagersErrors, PostBotsByBotIdChannelManagersResponses, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyError, PostBotsByBotIdConnectorsApiKeyErrors, PostBotsByBotIdConnectorsApiKeyResponse, PostBotsByBotIdConnectorsApiKeyResponses, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthError, PostBotsByBotIdConnectorsByConnectionIdReauthErrors, PostBotsByBotIdConnectorsByConnectionIdReauthResponse, PostBotsByBotIdConnectorsByConnectionIdReauthResponses, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthError, PostBotsByBotIdConnectorsOauthErrors, PostBotsByBotIdConnectorsOauthResponse, PostBotsByBotIdConnectorsOauthResponses, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveError, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveErrors, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponse, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponses, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsError, PostBotsByBotIdContainerBrowserSessionsErrors, PostBotsByBotIdContainerBrowserSessionsResponse, PostBotsByBotIdContainerBrowserSessionsResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreError, PostBotsByBotIdContainerDataRestoreErrors, PostBotsByBotIdContainerDataRestoreResponse, PostBotsByBotIdContainerDataRestoreResponses, PostBotsByBotIdContainerDisplayPrepareData, PostBotsByBotIdContainerDisplayPrepareError, PostBotsByBotIdContainerDisplayPrepareErrors, PostBotsByBotIdContainerDisplayPrepareResponse, PostBotsByBotIdContainerDisplayPrepareResponses, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferError, PostBotsByBotIdContainerDisplayWebrtcOfferErrors, PostBotsByBotIdContainerDisplayWebrtcOfferResponse, PostBotsByBotIdContainerDisplayWebrtcOfferResponses, PostBotsByBotIdContainerError, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveError, PostBotsByBotIdContainerFsArchiveErrors, PostBotsByBotIdContainerFsArchiveResponses, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteError, PostBotsByBotIdContainerFsDeleteErrors, PostBotsByBotIdContainerFsDeleteResponse, PostBotsByBotIdContainerFsDeleteResponses, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractError, PostBotsByBotIdContainerFsExtractErrors, PostBotsByBotIdContainerFsExtractResponse, PostBotsByBotIdContainerFsExtractResponses, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirError, PostBotsByBotIdContainerFsMkdirErrors, PostBotsByBotIdContainerFsMkdirResponse, PostBotsByBotIdContainerFsMkdirResponses, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameError, PostBotsByBotIdContainerFsRenameErrors, PostBotsByBotIdContainerFsRenameResponse, PostBotsByBotIdContainerFsRenameResponses, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadError, PostBotsByBotIdContainerFsUploadErrors, PostBotsByBotIdContainerFsUploadResponse, PostBotsByBotIdContainerFsUploadResponses, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteError, PostBotsByBotIdContainerFsWriteErrors, PostBotsByBotIdContainerFsWriteResponse, PostBotsByBotIdContainerFsWriteResponses, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsError, PostBotsByBotIdContainerSkillsActionsErrors, PostBotsByBotIdContainerSkillsActionsResponse, PostBotsByBotIdContainerSkillsActionsResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackError, PostBotsByBotIdContainerSnapshotsRollbackErrors, PostBotsByBotIdContainerSnapshotsRollbackResponse, PostBotsByBotIdContainerSnapshotsRollbackResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsError, PostBotsByBotIdEmailBindingsErrors, PostBotsByBotIdEmailBindingsResponse, PostBotsByBotIdEmailBindingsResponses, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestError, PostBotsByBotIdHooksTestErrors, PostBotsByBotIdHooksTestResponse, PostBotsByBotIdHooksTestResponses, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeError, PostBotsByBotIdMcpByIdOauthAuthorizeErrors, PostBotsByBotIdMcpByIdOauthAuthorizeResponse, PostBotsByBotIdMcpByIdOauthAuthorizeResponses, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverError, PostBotsByBotIdMcpByIdOauthDiscoverErrors, PostBotsByBotIdMcpByIdOauthDiscoverResponse, PostBotsByBotIdMcpByIdOauthDiscoverResponses, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeError, PostBotsByBotIdMcpByIdOauthExchangeErrors, PostBotsByBotIdMcpByIdOauthExchangeResponse, PostBotsByBotIdMcpByIdOauthExchangeResponses, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeError, PostBotsByBotIdMcpByIdProbeErrors, PostBotsByBotIdMcpByIdProbeResponse, PostBotsByBotIdMcpByIdProbeResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestError, PostBotsByBotIdMemoryIngestErrors, PostBotsByBotIdMemoryIngestResponse, PostBotsByBotIdMemoryIngestResponses, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteError, PostBotsByBotIdQuickActionsExecuteErrors, PostBotsByBotIdQuickActionsExecuteResponse, PostBotsByBotIdQuickActionsExecuteResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponse, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeError, PostBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactError, PostBotsByBotIdSessionsBySessionIdCompactErrors, PostBotsByBotIdSessionsBySessionIdCompactResponse, PostBotsByBotIdSessionsBySessionIdCompactResponses, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkError, PostBotsByBotIdSessionsBySessionIdForkErrors, PostBotsByBotIdSessionsBySessionIdForkResponse, PostBotsByBotIdSessionsBySessionIdForkResponses, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsError, PostBotsByBotIdSessionsErrors, PostBotsByBotIdSessionsResponse, PostBotsByBotIdSessionsResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageError, PostBotsByBotIdSupermarketInstallPackageErrors, PostBotsByBotIdSupermarketInstallPackageResponse, PostBotsByBotIdSupermarketInstallPackageResponses, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveError, PostBotsByBotIdToolApprovalsByApprovalIdApproveErrors, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponse, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponses, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectError, PostBotsByBotIdToolApprovalsByApprovalIdRejectErrors, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponse, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponse, PostBotsByBotIdToolsResponses, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeError, PostBotsByBotIdTtsSynthesizeErrors, PostBotsByBotIdTtsSynthesizeResponse, PostBotsByBotIdTtsSynthesizeResponses, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessError, PostBotsByBotIdUserAccessErrors, PostBotsByBotIdUserAccessResponse, PostBotsByBotIdUserAccessResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponse, PostBotsByBotIdWebMessagesResponses, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsError, PostBotsByBotIdWorkdirsErrors, PostBotsByBotIdWorkdirsResponse, PostBotsByBotIdWorkdirsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformSendResponses, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointError, PostBotsByIdChannelByPlatformWebhookEndpointErrors, PostBotsByIdChannelByPlatformWebhookEndpointResponse, PostBotsByIdChannelByPlatformWebhookEndpointResponses, PostBotsData, PostBotsError, PostBotsErrors, PostBotsResponse, PostBotsResponses, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdError, PostEmailMailgunWebhookByConfigIdErrors, PostEmailMailgunWebhookByConfigIdResponse, PostEmailMailgunWebhookByConfigIdResponses, PostEmailProvidersData, PostEmailProvidersError, PostEmailProvidersErrors, PostEmailProvidersResponse, PostEmailProvidersResponses, PostFetchProvidersData, PostFetchProvidersError, PostFetchProvidersErrors, PostFetchProvidersResponse, PostFetchProvidersResponses, PostMemoryProvidersData, PostMemoryProvidersError, PostMemoryProvidersErrors, PostMemoryProvidersResponse, PostMemoryProvidersResponses, PostModelsByIdTestData, PostModelsByIdTestError, PostModelsByIdTestErrors, PostModelsByIdTestResponse, PostModelsByIdTestResponses, PostModelsData, PostModelsError, PostModelsErrors, PostModelsResponse, PostModelsResponses, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsError, PostProvidersByIdImportModelsErrors, PostProvidersByIdImportModelsResponse, PostProvidersByIdImportModelsResponses, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollError, PostProvidersByIdOauthPollErrors, PostProvidersByIdOauthPollResponse, PostProvidersByIdOauthPollResponses, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestErrors, PostProvidersByIdTestResponse, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersError, PostProvidersErrors, PostProvidersFromTemplateData, PostProvidersFromTemplateError, PostProvidersFromTemplateErrors, PostProvidersFromTemplateResponse, PostProvidersFromTemplateResponses, PostProvidersResponse, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersErrors, PostSearchProvidersResponse, PostSearchProvidersResponses, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestError, PostSpeechModelsByIdTestErrors, PostSpeechModelsByIdTestResponses, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsError, PostSpeechProvidersByIdImportModelsErrors, PostSpeechProvidersByIdImportModelsResponse, PostSpeechProvidersByIdImportModelsResponses, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestError, PostTranscriptionModelsByIdTestErrors, PostTranscriptionModelsByIdTestResponse, PostTranscriptionModelsByIdTestResponses, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsError, PostTranscriptionProvidersByIdImportModelsErrors, PostTranscriptionProvidersByIdImportModelsResponse, PostTranscriptionProvidersByIdImportModelsResponses, PostUsersData, PostUsersError, PostUsersErrors, PostUsersMeChannelLinksData, PostUsersMeChannelLinksError, PostUsersMeChannelLinksErrors, PostUsersMeChannelLinksResponse, PostUsersMeChannelLinksResponses, PostUsersMeRuntimesData, PostUsersMeRuntimesError, PostUsersMeRuntimesErrors, PostUsersMeRuntimesResponse, PostUsersMeRuntimesResponses, PostUsersResponse, PostUsersResponses, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsError, PostVideoProvidersByIdImportModelsErrors, PostVideoProvidersByIdImportModelsResponse, PostVideoProvidersByIdImportModelsResponses, ProvidersCountResponse, ProvidersCreateFromTemplateRequest, ProvidersCreateRequest, ProvidersGetResponse, ProvidersImportModelsRequest, ProvidersImportModelsResponse, ProvidersOAuthAccount, ProvidersOAuthAuthorizeResponse, ProvidersOAuthDeviceStatus, ProvidersOAuthStatus, ProvidersTestResponse, ProvidersTestStatus, ProvidersUpdateRequest, ProvidertemplatesGetResponse, ProvidertemplatesModelResponse, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectError, PutBotsByBotIdAclDefaultEffectErrors, PutBotsByBotIdAclDefaultEffectResponses, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdError, PutBotsByBotIdAclRulesByRuleIdErrors, PutBotsByBotIdAclRulesByRuleIdResponse, PutBotsByBotIdAclRulesByRuleIdResponses, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialError, PutBotsByBotIdAgentsByIdCredentialErrors, PutBotsByBotIdAgentsByIdCredentialResponse, PutBotsByBotIdAgentsByIdCredentialResponses, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsError, PutBotsByBotIdContainerMetricsErrors, PutBotsByBotIdContainerMetricsResponse, PutBotsByBotIdContainerMetricsResponses, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdError, PutBotsByBotIdEmailBindingsByIdErrors, PutBotsByBotIdEmailBindingsByIdResponse, PutBotsByBotIdEmailBindingsByIdResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportError, PutBotsByBotIdMcpOpsImportErrors, PutBotsByBotIdMcpOpsImportResponse, PutBotsByBotIdMcpOpsImportResponses, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdError, PutBotsByBotIdMemoryByMemoryIdErrors, PutBotsByBotIdMemoryByMemoryIdResponse, PutBotsByBotIdMemoryByMemoryIdResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSettingsResponses, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdError, PutBotsByBotIdUserAccessByGrantIdErrors, PutBotsByBotIdUserAccessByGrantIdResponse, PutBotsByBotIdUserAccessByGrantIdResponses, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalError, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalErrors, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalResponses, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryError, PutBotsByBotIdWorkspaceTargetsPrimaryErrors, PutBotsByBotIdWorkspaceTargetsPrimaryResponses, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdErrors, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponse, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponse, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdError, PutBotsByIdErrors, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponse, PutBotsByIdOwnerResponses, PutBotsByIdResponse, PutBotsByIdResponses, PutEmailProvidersByIdData, PutEmailProvidersByIdError, PutEmailProvidersByIdErrors, PutEmailProvidersByIdResponse, PutEmailProvidersByIdResponses, PutFetchProvidersByIdData, PutFetchProvidersByIdError, PutFetchProvidersByIdErrors, PutFetchProvidersByIdResponse, PutFetchProvidersByIdResponses, PutMemoryProvidersByIdData, PutMemoryProvidersByIdError, PutMemoryProvidersByIdErrors, PutMemoryProvidersByIdResponse, PutMemoryProvidersByIdResponses, PutModelsByIdData, PutModelsByIdError, PutModelsByIdErrors, PutModelsByIdResponse, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponse, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdErrors, PutProvidersByIdResponse, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponse, PutSearchProvidersByIdResponses, PutSpeechModelsByIdData, PutSpeechModelsByIdError, PutSpeechModelsByIdErrors, PutSpeechModelsByIdResponse, PutSpeechModelsByIdResponses, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdError, PutTranscriptionModelsByIdErrors, PutTranscriptionModelsByIdResponse, PutTranscriptionModelsByIdResponses, PutUsersByIdData, PutUsersByIdError, PutUsersByIdErrors, PutUsersByIdResponse, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponse, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeError, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponse, PutUsersMeResponses, PutVideoModelsByIdData, PutVideoModelsByIdError, PutVideoModelsByIdErrors, PutVideoModelsByIdResponse, PutVideoModelsByIdResponses, ReasoningOptions, ScheduleCreateRequest, ScheduleExecutionConfig, ScheduleListLogsResponse, ScheduleListResponse, ScheduleLog, ScheduleNullableInt, ScheduleSchedule, ScheduleUpdateRequest, SearchprovidersCreateRequest, SearchprovidersGetResponse, SearchprovidersProviderConfigSchema, SearchprovidersProviderFieldSchema, SearchprovidersProviderMeta, SearchprovidersProviderName, SearchprovidersUpdateRequest, SessionSession, SettingsSettings, SettingsToolApprovalConfig, SettingsToolApprovalExecPolicy, SettingsToolApprovalFilePolicy, SettingsToolApprovalMode, SettingsUpsertRequest, SkillpackagesInstallation, SkillsSafeCatalogItem, SupermarketUninstallPackageResponse, UserinputUiAnswer, UserinputUiOption, UserinputUiQuestion, UserruntimeCreateRuntimeRequest, UserruntimeRuntime, VideoConfigSchema, VideoFieldSchema, VideoImportModelsResponse, VideoModelInfo, VideoModelResponse, VideoProviderMetaResponse, VideoProviderResponse, VideoUpdateModelRequest, WebhooktunnelStatus, WorkdirCreateRequest, WorkdirUpdateRequest, WorkdirWorkdir, WorkdirWorkdirsResponse, WorkspaceSetPrimaryWorkspaceTargetRequest, WorkspaceUpdateWorkspaceTargetToolApprovalRequest, WorkspaceWorkspaceTarget, WorkspaceWorkspaceTargetGrant, WorkspaceWorkspaceTargetGrantsResponse, WorkspaceWorkspaceTargetsResponse, WorkspaceWorkspaceTargetToolApproval } from './types.gen'; +export { deleteBotsByBotIdAclRulesByRuleId, deleteBotsByBotIdAcpRuntimesByRuntimeId, deleteBotsByBotIdAgentsById, deleteBotsByBotIdAgentsByIdCredential, deleteBotsByBotIdChannelManagersByChannelIdentityId, deleteBotsByBotIdCompactionLogs, deleteBotsByBotIdConnectorsByConnectionId, deleteBotsByBotIdContainer, deleteBotsByBotIdContainerBrowserSessionsBySessionId, deleteBotsByBotIdContainerDisplaySessionsBySessionId, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdEmailBindingsById, deleteBotsByBotIdMcpById, deleteBotsByBotIdMcpByIdOauthToken, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdMessages, deleteBotsByBotIdScheduleById, deleteBotsByBotIdScheduleLogs, deleteBotsByBotIdSessionsBySessionId, deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId, deleteBotsByBotIdSettings, deleteBotsByBotIdSupermarketPackagesByInstallationId, deleteBotsByBotIdUserAccessByGrantId, deleteBotsByBotIdWorkdirsByWorkdirId, deleteBotsByBotIdWorkspaceTargetsByTargetId, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteEmailProvidersById, deleteEmailProvidersByIdOauthToken, deleteFetchProvidersById, deleteMemoryProvidersById, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteProvidersByIdOauthToken, deleteSearchProvidersById, deleteUsersById, deleteUsersMeChannelIdentitiesByChannelIdentityId, deleteUsersMeRuntimesById, getAcpProfiles, getBots, getBotsByBotIdAclChannelIdentities, getBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversations, getBotsByBotIdAclChannelTypesByChannelTypeConversations, getBotsByBotIdAclDefaultEffect, getBotsByBotIdAclRules, getBotsByBotIdAcpRuntimesByRuntimeId, getBotsByBotIdAgents, getBotsByBotIdAgentsById, getBotsByBotIdAgentsByIdCredential, getBotsByBotIdAgentsByIdModels, getBotsByBotIdBackupSummary, getBotsByBotIdChannelManagers, getBotsByBotIdCompactionLogs, getBotsByBotIdConnectors, getBotsByBotIdConnectorsByConnectionId, getBotsByBotIdContainer, getBotsByBotIdContainerDisplay, getBotsByBotIdContainerDisplaySessions, getBotsByBotIdContainerFs, getBotsByBotIdContainerFsDownload, getBotsByBotIdContainerFsList, getBotsByBotIdContainerFsRead, getBotsByBotIdContainerMetrics, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdContainerTerminal, getBotsByBotIdContainerTerminalWs, getBotsByBotIdEmailBindings, getBotsByBotIdEmailOutbox, getBotsByBotIdEmailOutboxById, getBotsByBotIdHooksEvents, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpByIdOauthStatus, getBotsByBotIdMcpOpsExport, getBotsByBotIdMemory, getBotsByBotIdMemoryGraph, getBotsByBotIdMemoryStatus, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdMessagesLocate, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdScheduleByIdLogs, getBotsByBotIdScheduleLogs, getBotsByBotIdSessions, getBotsByBotIdSessionsBySessionId, getBotsByBotIdSessionsBySessionIdAcpRuntime, getBotsByBotIdSessionsBySessionIdContextLifecycle, getBotsByBotIdSessionsBySessionIdFollowUpQueue, getBotsByBotIdSessionsBySessionIdQueue, getBotsByBotIdSessionsBySessionIdStatus, getBotsByBotIdSessionsBySessionIdSteerQueue, getBotsByBotIdSessionsEvents, getBotsByBotIdSessionsModelPreferenceSeed, getBotsByBotIdSettings, getBotsByBotIdSkillsCatalog, getBotsByBotIdSupermarketPackages, getBotsByBotIdTokenUsage, getBotsByBotIdTokenUsageRecords, getBotsByBotIdUserAccess, getBotsByBotIdUserAccessCandidates, getBotsByBotIdWebStream, getBotsByBotIdWebWs, getBotsByBotIdWorkdirs, getBotsByBotIdWorkspaceTargets, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsNameAvailability, getBotsUserAccessCandidates, getChannels, getChannelsByPlatform, getConnectorsCatalog, getEmailOauthCallback, getEmailProviders, getEmailProvidersById, getEmailProvidersByIdOauthAuthorize, getEmailProvidersByIdOauthStatus, getEmailProvidersMeta, getFetchProviders, getFetchProvidersById, getFetchProvidersMeta, getMemoryProviders, getMemoryProvidersById, getMemoryProvidersByIdStatus, getMemoryProvidersMeta, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getOauthMcpCallback, getPing, getProviders, getProvidersById, getProvidersByIdModels, getProvidersByIdOauthAuthorize, getProvidersByIdOauthStatus, getProvidersCount, getProvidersNameByName, getProvidersOauthCallback, getProviderTemplates, getProviderTemplatesById, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getSpeechModels, getSpeechModelsById, getSpeechModelsByIdCapabilities, getSpeechProviders, getSpeechProvidersById, getSpeechProvidersByIdModels, getSpeechProvidersMeta, getSupermarketArtifactsIconByDigest, getSupermarketPackages, getSupermarketRegistries, getSupermarketRegistriesByRegistryIdCategories, getSupermarketRegistriesByRegistryIdPackages, getSupermarketRegistriesByRegistryIdPackagesByPackageId, getSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevision, getSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillId, getSupermarketSkills, getTranscriptionModels, getTranscriptionModelsById, getTranscriptionModelsByIdCapabilities, getTranscriptionProviders, getTranscriptionProvidersById, getTranscriptionProvidersByIdModels, getTranscriptionProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelIdentities, getUsersMeChannelsByPlatform, getUsersMeComputerAccess, getUsersMeRuntimes, getVideoModels, getVideoModelsById, getVideoProviders, getVideoProvidersById, getVideoProvidersByIdModels, getVideoProvidersMeta, getWebhookTunnelStatus, type Options, patchBotsByBotIdAcpRuntimesByRuntimeIdMode, patchBotsByBotIdAcpRuntimesByRuntimeIdModel, patchBotsByBotIdAcpRuntimesByRuntimeIdReasoning, patchBotsByBotIdAgentsById, patchBotsByBotIdConnectorsByConnectionId, patchBotsByBotIdSessionsBySessionId, patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode, patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel, patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning, patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId, patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId, patchBotsByBotIdWorkdirsByWorkdirId, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postAuthRefresh, postBots, postBotsBackupImport, postBotsBackupImportPreview, postBotsByBotIdAclRules, postBotsByBotIdAcpRuntimes, postBotsByBotIdAgents, postBotsByBotIdAgentsByIdCodexLoginDeviceAuthorize, postBotsByBotIdAgentsByIdCodexLoginDeviceCancel, postBotsByBotIdAgentsByIdCodexLoginDevicePoll, postBotsByBotIdBackupExport, postBotsByBotIdChannelManagers, postBotsByBotIdConnectorsApiKey, postBotsByBotIdConnectorsByConnectionIdReauth, postBotsByBotIdConnectorsOauth, postBotsByBotIdContainer, postBotsByBotIdContainerBrowserSessions, postBotsByBotIdContainerBrowserSessionsBySessionIdKeepalive, postBotsByBotIdContainerDataRestore, postBotsByBotIdContainerDisplayPrepare, postBotsByBotIdContainerDisplayWebrtcOffer, postBotsByBotIdContainerFsArchive, postBotsByBotIdContainerFsDelete, postBotsByBotIdContainerFsExtract, postBotsByBotIdContainerFsMkdir, postBotsByBotIdContainerFsRename, postBotsByBotIdContainerFsUpload, postBotsByBotIdContainerFsWrite, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSkillsActions, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshotsRollback, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdEmailBindings, postBotsByBotIdHooksTest, postBotsByBotIdMcp, postBotsByBotIdMcpByIdOauthAuthorize, postBotsByBotIdMcpByIdOauthDiscover, postBotsByBotIdMcpByIdOauthExchange, postBotsByBotIdMcpByIdProbe, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryIngest, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdQuickActionsExecute, postBotsByBotIdSchedule, postBotsByBotIdSessions, postBotsByBotIdSessionsBySessionIdAcpRuntime, postBotsByBotIdSessionsBySessionIdCompact, postBotsByBotIdSessionsBySessionIdFollowUpQueue, postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer, postBotsByBotIdSessionsBySessionIdFork, postBotsByBotIdSessionsBySessionIdSteerQueue, postBotsByBotIdSettings, postBotsByBotIdSupermarketInstallPackage, postBotsByBotIdToolApprovalsByApprovalIdApprove, postBotsByBotIdToolApprovalsByApprovalIdReject, postBotsByBotIdTools, postBotsByBotIdTtsSynthesize, postBotsByBotIdUserAccess, postBotsByBotIdWebMessages, postBotsByBotIdWorkdirs, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postBotsByIdChannelByPlatformWebhookEndpoint, postEmailMailgunWebhookByConfigId, postEmailProviders, postFetchProviders, postMemoryProviders, postModels, postModelsByIdTest, postProviders, postProvidersByIdImportModels, postProvidersByIdOauthPoll, postProvidersByIdTest, postProvidersFromTemplate, postSearchProviders, postSpeechModelsByIdTest, postSpeechProvidersByIdImportModels, postTranscriptionModelsByIdTest, postTranscriptionProvidersByIdImportModels, postUsers, postUsersMeChannelLinks, postUsersMeRuntimes, postVideoProvidersByIdImportModels, putBotsByBotIdAclDefaultEffect, putBotsByBotIdAclRulesByRuleId, putBotsByBotIdAgentsByIdCredential, putBotsByBotIdContainerMetrics, putBotsByBotIdEmailBindingsById, putBotsByBotIdMcpById, putBotsByBotIdMcpOpsImport, putBotsByBotIdMemoryByMemoryId, putBotsByBotIdScheduleById, putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder, putBotsByBotIdSessionsBySessionIdSteerQueueReorder, putBotsByBotIdSettings, putBotsByBotIdUserAccessByGrantId, putBotsByBotIdWorkspaceTargetsByTargetIdToolApproval, putBotsByBotIdWorkspaceTargetsPrimary, putBotsByBotIdWorkspaceTargetsRemotesByRuntimeId, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdOwner, putEmailProvidersById, putFetchProvidersById, putMemoryProvidersById, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putSpeechModelsById, putTranscriptionModelsById, putUsersById, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword, putVideoModelsById } from './sdk.gen'; +export type { AccountsAccount, AccountsCreateAccountRequest, AccountsListAccountsResponse, AccountsUpdateAccountRequest, AccountsUpdatePasswordRequest, AccountsUpdateProfileMetadata, AccountsUpdateProfileRequest, AclChannelIdentityCandidate, AclChannelIdentityCandidateListResponse, AclCreateRuleRequest, AclDefaultEffectResponse, AclListRulesResponse, AclObservedConversationCandidate, AclObservedConversationCandidateListResponse, AclRule, AclSourceScope, AclUpdateRuleRequest, AcpagentRuntimeStatus, AcpclientAvailableCommandInfo, AcpclientModeInfo, AcpclientModelInfo, AcpclientModelState, AcpclientModeState, AcpclientReasoningEffortInfo, AcpclientReasoningState, AcpprofileManagedField, AcpprofileProfilesResponse, AcpprofilePublicProfile, AdaptersCompactResult, AdaptersDeleteResponse, AdaptersHealthStatus, AdaptersIngestResult, AdaptersMemoryCompactCapability, AdaptersMemoryItem, AdaptersMemoryStatusResponse, AdaptersMessage, AdaptersProviderCollectionStatus, AdaptersProviderConfigSchema, AdaptersProviderCreateRequest, AdaptersProviderFieldSchema, AdaptersProviderGetResponse, AdaptersProviderMeta, AdaptersProviderStatusResponse, AdaptersProviderType, AdaptersProviderUpdateRequest, AdaptersRebuildResult, AdaptersSearchResponse, AdaptersUsageResponse, AgentcredentialPublicCredential, ApperrorProblem, AudioConfigSchema, AudioFieldSchema, AudioImportModelsResponse, AudioModelCapabilities, AudioModelInfo, AudioParamConstraint, AudioProviderMetaResponse, AudioSpeechModelResponse, AudioSpeechProviderResponse, AudioTestSynthesizeRequest, AudioTestTranscriptionResponse, AudioTranscriptionModelResponse, AudioTranscriptionWord, AudioUpdateSpeechModelRequest, AudioVoiceInfo, BotagentsBotAgent, BotagentsCreateRequest, BotagentsListResponse, BotagentsUpdateRequest, BotbackupExportRequest, BotbackupImportMode, BotbackupImportResult, BotbackupManifest, BotbackupManifestEntry, BotbackupManifestOptions, BotbackupPreviewResult, BotbackupProfilePreview, BotbackupRestorePlan, BotbackupSection, BotbackupSectionSummary, BotbackupSummaryResult, BotsBot, BotsBotCheck, BotsCreateBotRequest, BotsCreateUserGrantRequest, BotsListBotsResponse, BotsListChecksResponse, BotsNameAvailability, BotsTransferBotRequest, BotsUpdateBotRequest, BotsUpdateUserGrantRequest, BotsUserGrant, ChannelaccessBinding, ChannelaccessIssueLinkCodeRequest, ChannelaccessLinkCode, ChannelaccessListBindingsResponse, ChannelaccessListManagersResponse, ChannelaccessManager, ChannelaccessSetManagerRequest, ChannelAction, ChannelAttachment, ChannelAttachmentType, ChannelChannelCapabilities, ChannelChannelConfig, ChannelChannelIdentityBinding, ChannelChannelType, ChannelConfigSchema, ChannelFieldSchema, ChannelFieldType, ChannelForwardRef, ChannelMessage, ChannelMessageFormat, ChannelMessagePart, ChannelMessagePartType, ChannelMessageTextStyle, ChannelReplyRef, ChannelSendRequest, ChannelSetWebhookEndpointRequest, ChannelSetWebhookEndpointResponse, ChannelTargetHint, ChannelTargetSpec, ChannelThreadRef, ChannelUpdateChannelStatusRequest, ChannelUpsertChannelIdentityConfigRequest, ChannelUpsertConfigRequest, ClientOptions, CompactionListLogsResponse, CompactionLog, ConnectitAuthMethod, ConnectitConnector, ConnectitCredentialField, ConnectitOAuthAuthorization, ConnectorsConnector, ConnectorsListResponse, ContextfragCacheClass, ContextfragCacheComparison, ContextfragCacheUsageRecord, ContextfragContentRange, ContextfragContextBudgetPlan, ContextfragContextRef, ContextfragKind, ContextfragKindBreakdown, ContextfragLifecycleSnapshot, ContextfragManifestCounts, ContextfragManifestView, ContextfragMemoryRecallQueryTrace, ContextfragMemoryRecallResultTrace, ContextfragMemoryRecallTrace, ContextfragMutationKind, ContextfragMutationRecord, ContextfragRefDurability, ContextfragRetentionTier, ContextfragSelectionDecision, ContextfragSelectionDecisionKind, ContextfragSelectionTrace, ContextfragSlot, ContextfragStepSnapshot, ContextfragToolDefAccounting, ContextfragTrustBreakdown, ContextfragTrustLevel, ConversationSkillActivation, ConversationSkillActivationSkill, ConversationUiAttachment, ConversationUiBackgroundTask, ConversationUiExecutionLocation, ConversationUiForwardRef, ConversationUiMessage, ConversationUiMessageType, ConversationUiReasoningTiming, ConversationUiReplyRef, ConversationUiToolApproval, ConversationUiToolApprovalOption, ConversationUiTurn, ConversationUiUserInput, DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdError, DeleteBotsByBotIdAclRulesByRuleIdErrors, DeleteBotsByBotIdAclRulesByRuleIdResponses, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdError, DeleteBotsByBotIdAcpRuntimesByRuntimeIdErrors, DeleteBotsByBotIdAcpRuntimesByRuntimeIdResponses, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialError, DeleteBotsByBotIdAgentsByIdCredentialErrors, DeleteBotsByBotIdAgentsByIdCredentialResponses, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdError, DeleteBotsByBotIdAgentsByIdErrors, DeleteBotsByBotIdAgentsByIdResponses, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdError, DeleteBotsByBotIdChannelManagersByChannelIdentityIdErrors, DeleteBotsByBotIdChannelManagersByChannelIdentityIdResponses, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsError, DeleteBotsByBotIdCompactionLogsErrors, DeleteBotsByBotIdCompactionLogsResponses, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdError, DeleteBotsByBotIdConnectorsByConnectionIdErrors, DeleteBotsByBotIdConnectorsByConnectionIdResponses, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdError, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdErrors, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdResponses, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdError, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdErrors, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdResponses, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdError, DeleteBotsByBotIdEmailBindingsByIdErrors, DeleteBotsByBotIdEmailBindingsByIdResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenError, DeleteBotsByBotIdMcpByIdOauthTokenErrors, DeleteBotsByBotIdMcpByIdOauthTokenResponses, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesError, DeleteBotsByBotIdMessagesErrors, DeleteBotsByBotIdMessagesResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsError, DeleteBotsByBotIdScheduleLogsErrors, DeleteBotsByBotIdScheduleLogsResponses, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdError, DeleteBotsByBotIdSessionsBySessionIdErrors, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses, DeleteBotsByBotIdSessionsBySessionIdResponses, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdError, DeleteBotsByBotIdSupermarketPackagesByInstallationIdErrors, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponse, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponses, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdError, DeleteBotsByBotIdUserAccessByGrantIdErrors, DeleteBotsByBotIdUserAccessByGrantIdResponses, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdError, DeleteBotsByBotIdWorkdirsByWorkdirIdErrors, DeleteBotsByBotIdWorkdirsByWorkdirIdResponses, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdError, DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors, DeleteBotsByBotIdWorkspaceTargetsByTargetIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdErrors, DeleteBotsByIdResponse, DeleteBotsByIdResponses, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdError, DeleteEmailProvidersByIdErrors, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenError, DeleteEmailProvidersByIdOauthTokenErrors, DeleteEmailProvidersByIdOauthTokenResponses, DeleteEmailProvidersByIdResponses, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdError, DeleteFetchProvidersByIdErrors, DeleteFetchProvidersByIdResponses, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdError, DeleteMemoryProvidersByIdErrors, DeleteMemoryProvidersByIdResponses, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdErrors, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenError, DeleteProvidersByIdOauthTokenErrors, DeleteProvidersByIdOauthTokenResponses, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, DeleteUsersByIdData, DeleteUsersByIdError, DeleteUsersByIdErrors, DeleteUsersByIdResponses, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdError, DeleteUsersMeChannelIdentitiesByChannelIdentityIdErrors, DeleteUsersMeChannelIdentitiesByChannelIdentityIdResponses, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdError, DeleteUsersMeRuntimesByIdErrors, DeleteUsersMeRuntimesByIdResponses, DisplaySessionInfo, EmailBindingResponse, EmailConfigSchema, EmailCreateBindingRequest, EmailCreateProviderRequest, EmailFieldSchema, EmailOutboxItemResponse, EmailProviderMeta, EmailProviderResponse, EmailUpdateBindingRequest, EmailUpdateProviderRequest, ExternalagentCodexDeviceLoginAuthorizeResponse, ExternalagentCodexDeviceLoginPollRequest, ExternalagentCodexDeviceLoginPollResponse, ExternalModelCatalog, ExternalModelOption, ExternalReasoningEffortOption, FetchprovidersCreateRequest, FetchprovidersGetResponse, FetchprovidersProviderConfigSchema, FetchprovidersProviderFieldSchema, FetchprovidersProviderMeta, FetchprovidersProviderName, FetchprovidersUpdateRequest, GetAcpProfilesData, GetAcpProfilesResponse, GetAcpProfilesResponses, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsError, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponse, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesError, GetBotsByBotIdAclChannelIdentitiesErrors, GetBotsByBotIdAclChannelIdentitiesResponse, GetBotsByBotIdAclChannelIdentitiesResponses, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsError, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponse, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectError, GetBotsByBotIdAclDefaultEffectErrors, GetBotsByBotIdAclDefaultEffectResponse, GetBotsByBotIdAclDefaultEffectResponses, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesError, GetBotsByBotIdAclRulesErrors, GetBotsByBotIdAclRulesResponse, GetBotsByBotIdAclRulesResponses, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdError, GetBotsByBotIdAcpRuntimesByRuntimeIdErrors, GetBotsByBotIdAcpRuntimesByRuntimeIdResponse, GetBotsByBotIdAcpRuntimesByRuntimeIdResponses, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialError, GetBotsByBotIdAgentsByIdCredentialErrors, GetBotsByBotIdAgentsByIdCredentialResponse, GetBotsByBotIdAgentsByIdCredentialResponses, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdError, GetBotsByBotIdAgentsByIdErrors, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsError, GetBotsByBotIdAgentsByIdModelsErrors, GetBotsByBotIdAgentsByIdModelsResponse, GetBotsByBotIdAgentsByIdModelsResponses, GetBotsByBotIdAgentsByIdResponse, GetBotsByBotIdAgentsByIdResponses, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsError, GetBotsByBotIdAgentsErrors, GetBotsByBotIdAgentsResponse, GetBotsByBotIdAgentsResponses, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryError, GetBotsByBotIdBackupSummaryErrors, GetBotsByBotIdBackupSummaryResponse, GetBotsByBotIdBackupSummaryResponses, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersError, GetBotsByBotIdChannelManagersErrors, GetBotsByBotIdChannelManagersResponse, GetBotsByBotIdChannelManagersResponses, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsError, GetBotsByBotIdCompactionLogsErrors, GetBotsByBotIdCompactionLogsResponse, GetBotsByBotIdCompactionLogsResponses, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdError, GetBotsByBotIdConnectorsByConnectionIdErrors, GetBotsByBotIdConnectorsByConnectionIdResponse, GetBotsByBotIdConnectorsByConnectionIdResponses, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsError, GetBotsByBotIdConnectorsErrors, GetBotsByBotIdConnectorsResponse, GetBotsByBotIdConnectorsResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayError, GetBotsByBotIdContainerDisplayErrors, GetBotsByBotIdContainerDisplayResponse, GetBotsByBotIdContainerDisplayResponses, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsError, GetBotsByBotIdContainerDisplaySessionsErrors, GetBotsByBotIdContainerDisplaySessionsResponse, GetBotsByBotIdContainerDisplaySessionsResponses, GetBotsByBotIdContainerError, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadError, GetBotsByBotIdContainerFsDownloadErrors, GetBotsByBotIdContainerFsDownloadResponses, GetBotsByBotIdContainerFsError, GetBotsByBotIdContainerFsErrors, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListError, GetBotsByBotIdContainerFsListErrors, GetBotsByBotIdContainerFsListResponse, GetBotsByBotIdContainerFsListResponses, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadError, GetBotsByBotIdContainerFsReadErrors, GetBotsByBotIdContainerFsReadResponse, GetBotsByBotIdContainerFsReadResponses, GetBotsByBotIdContainerFsResponse, GetBotsByBotIdContainerFsResponses, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsError, GetBotsByBotIdContainerMetricsErrors, GetBotsByBotIdContainerMetricsResponse, GetBotsByBotIdContainerMetricsResponses, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsError, GetBotsByBotIdContainerSnapshotsErrors, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalError, GetBotsByBotIdContainerTerminalErrors, GetBotsByBotIdContainerTerminalResponse, GetBotsByBotIdContainerTerminalResponses, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsError, GetBotsByBotIdContainerTerminalWsErrors, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsError, GetBotsByBotIdEmailBindingsErrors, GetBotsByBotIdEmailBindingsResponse, GetBotsByBotIdEmailBindingsResponses, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdError, GetBotsByBotIdEmailOutboxByIdErrors, GetBotsByBotIdEmailOutboxByIdResponse, GetBotsByBotIdEmailOutboxByIdResponses, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxError, GetBotsByBotIdEmailOutboxErrors, GetBotsByBotIdEmailOutboxResponse, GetBotsByBotIdEmailOutboxResponses, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsError, GetBotsByBotIdHooksEventsErrors, GetBotsByBotIdHooksEventsResponse, GetBotsByBotIdHooksEventsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusError, GetBotsByBotIdMcpByIdOauthStatusErrors, GetBotsByBotIdMcpByIdOauthStatusResponse, GetBotsByBotIdMcpByIdOauthStatusResponses, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportError, GetBotsByBotIdMcpOpsExportErrors, GetBotsByBotIdMcpOpsExportResponse, GetBotsByBotIdMcpOpsExportResponses, GetBotsByBotIdMcpResponse, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphError, GetBotsByBotIdMemoryGraphErrors, GetBotsByBotIdMemoryGraphResponse, GetBotsByBotIdMemoryGraphResponses, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusError, GetBotsByBotIdMemoryStatusErrors, GetBotsByBotIdMemoryStatusResponse, GetBotsByBotIdMemoryStatusResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateError, GetBotsByBotIdMessagesLocateErrors, GetBotsByBotIdMessagesLocateResponse, GetBotsByBotIdMessagesLocateResponses, GetBotsByBotIdMessagesResponse, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsError, GetBotsByBotIdScheduleByIdLogsErrors, GetBotsByBotIdScheduleByIdLogsResponse, GetBotsByBotIdScheduleByIdLogsResponses, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsError, GetBotsByBotIdScheduleLogsErrors, GetBotsByBotIdScheduleLogsResponse, GetBotsByBotIdScheduleLogsResponses, GetBotsByBotIdScheduleResponse, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeError, GetBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleError, GetBotsByBotIdSessionsBySessionIdContextLifecycleErrors, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponse, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdError, GetBotsByBotIdSessionsBySessionIdErrors, GetBotsByBotIdSessionsBySessionIdFollowUpQueueData, GetBotsByBotIdSessionsBySessionIdFollowUpQueueError, GetBotsByBotIdSessionsBySessionIdFollowUpQueueErrors, GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponse, GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponses, GetBotsByBotIdSessionsBySessionIdQueueData, GetBotsByBotIdSessionsBySessionIdQueueError, GetBotsByBotIdSessionsBySessionIdQueueErrors, GetBotsByBotIdSessionsBySessionIdQueueResponse, GetBotsByBotIdSessionsBySessionIdQueueResponses, GetBotsByBotIdSessionsBySessionIdResponse, GetBotsByBotIdSessionsBySessionIdResponses, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusError, GetBotsByBotIdSessionsBySessionIdStatusErrors, GetBotsByBotIdSessionsBySessionIdStatusResponse, GetBotsByBotIdSessionsBySessionIdStatusResponses, GetBotsByBotIdSessionsBySessionIdSteerQueueData, GetBotsByBotIdSessionsBySessionIdSteerQueueError, GetBotsByBotIdSessionsBySessionIdSteerQueueErrors, GetBotsByBotIdSessionsBySessionIdSteerQueueResponse, GetBotsByBotIdSessionsBySessionIdSteerQueueResponses, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsError, GetBotsByBotIdSessionsErrors, GetBotsByBotIdSessionsEventsData, GetBotsByBotIdSessionsEventsError, GetBotsByBotIdSessionsEventsErrors, GetBotsByBotIdSessionsEventsResponse, GetBotsByBotIdSessionsEventsResponses, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedError, GetBotsByBotIdSessionsModelPreferenceSeedErrors, GetBotsByBotIdSessionsModelPreferenceSeedResponse, GetBotsByBotIdSessionsModelPreferenceSeedResponses, GetBotsByBotIdSessionsResponse, GetBotsByBotIdSessionsResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogError, GetBotsByBotIdSkillsCatalogErrors, GetBotsByBotIdSkillsCatalogResponse, GetBotsByBotIdSkillsCatalogResponses, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesError, GetBotsByBotIdSupermarketPackagesErrors, GetBotsByBotIdSupermarketPackagesResponse, GetBotsByBotIdSupermarketPackagesResponses, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageError, GetBotsByBotIdTokenUsageErrors, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsError, GetBotsByBotIdTokenUsageRecordsErrors, GetBotsByBotIdTokenUsageRecordsResponse, GetBotsByBotIdTokenUsageRecordsResponses, GetBotsByBotIdTokenUsageResponse, GetBotsByBotIdTokenUsageResponses, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesError, GetBotsByBotIdUserAccessCandidatesErrors, GetBotsByBotIdUserAccessCandidatesResponse, GetBotsByBotIdUserAccessCandidatesResponses, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessError, GetBotsByBotIdUserAccessErrors, GetBotsByBotIdUserAccessResponse, GetBotsByBotIdUserAccessResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamError, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponse, GetBotsByBotIdWebStreamResponses, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsError, GetBotsByBotIdWebWsErrors, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsError, GetBotsByBotIdWorkdirsErrors, GetBotsByBotIdWorkdirsResponse, GetBotsByBotIdWorkdirsResponses, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsError, GetBotsByBotIdWorkspaceTargetsErrors, GetBotsByBotIdWorkspaceTargetsResponse, GetBotsByBotIdWorkspaceTargetsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksErrors, GetBotsByIdChecksResponse, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdError, GetBotsByIdErrors, GetBotsByIdResponse, GetBotsByIdResponses, GetBotsData, GetBotsError, GetBotsErrors, GetBotsNameAvailabilityData, GetBotsNameAvailabilityError, GetBotsNameAvailabilityErrors, GetBotsNameAvailabilityResponse, GetBotsNameAvailabilityResponses, GetBotsResponse, GetBotsResponses, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesError, GetBotsUserAccessCandidatesErrors, GetBotsUserAccessCandidatesResponse, GetBotsUserAccessCandidatesResponses, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformErrors, GetChannelsByPlatformResponse, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetConnectorsCatalogData, GetConnectorsCatalogError, GetConnectorsCatalogErrors, GetConnectorsCatalogResponse, GetConnectorsCatalogResponses, GetEmailOauthCallbackData, GetEmailOauthCallbackError, GetEmailOauthCallbackErrors, GetEmailOauthCallbackResponse, GetEmailOauthCallbackResponses, GetEmailProvidersByIdData, GetEmailProvidersByIdError, GetEmailProvidersByIdErrors, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeError, GetEmailProvidersByIdOauthAuthorizeErrors, GetEmailProvidersByIdOauthAuthorizeResponse, GetEmailProvidersByIdOauthAuthorizeResponses, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusError, GetEmailProvidersByIdOauthStatusErrors, GetEmailProvidersByIdOauthStatusResponse, GetEmailProvidersByIdOauthStatusResponses, GetEmailProvidersByIdResponse, GetEmailProvidersByIdResponses, GetEmailProvidersData, GetEmailProvidersError, GetEmailProvidersErrors, GetEmailProvidersMetaData, GetEmailProvidersMetaResponse, GetEmailProvidersMetaResponses, GetEmailProvidersResponse, GetEmailProvidersResponses, GetFetchProvidersByIdData, GetFetchProvidersByIdError, GetFetchProvidersByIdErrors, GetFetchProvidersByIdResponse, GetFetchProvidersByIdResponses, GetFetchProvidersData, GetFetchProvidersError, GetFetchProvidersErrors, GetFetchProvidersMetaData, GetFetchProvidersMetaResponse, GetFetchProvidersMetaResponses, GetFetchProvidersResponse, GetFetchProvidersResponses, GetMemoryProvidersByIdData, GetMemoryProvidersByIdError, GetMemoryProvidersByIdErrors, GetMemoryProvidersByIdResponse, GetMemoryProvidersByIdResponses, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusError, GetMemoryProvidersByIdStatusErrors, GetMemoryProvidersByIdStatusResponse, GetMemoryProvidersByIdStatusResponses, GetMemoryProvidersData, GetMemoryProvidersError, GetMemoryProvidersErrors, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponse, GetMemoryProvidersMetaResponses, GetMemoryProvidersResponse, GetMemoryProvidersResponses, GetModelsByIdData, GetModelsByIdError, GetModelsByIdErrors, GetModelsByIdResponse, GetModelsByIdResponses, GetModelsCountData, GetModelsCountError, GetModelsCountErrors, GetModelsCountResponse, GetModelsCountResponses, GetModelsData, GetModelsError, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponse, GetModelsModelByModelIdResponses, GetModelsResponse, GetModelsResponses, GetOauthMcpCallbackData, GetOauthMcpCallbackError, GetOauthMcpCallbackErrors, GetOauthMcpCallbackResponse, GetOauthMcpCallbackResponses, GetPingData, GetPingResponse, GetPingResponses, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponse, GetProvidersByIdModelsResponses, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeError, GetProvidersByIdOauthAuthorizeErrors, GetProvidersByIdOauthAuthorizeResponse, GetProvidersByIdOauthAuthorizeResponses, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusError, GetProvidersByIdOauthStatusErrors, GetProvidersByIdOauthStatusResponse, GetProvidersByIdOauthStatusResponses, GetProvidersByIdResponse, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountError, GetProvidersCountErrors, GetProvidersCountResponse, GetProvidersCountResponses, GetProvidersData, GetProvidersError, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameErrors, GetProvidersNameByNameResponse, GetProvidersNameByNameResponses, GetProvidersOauthCallbackData, GetProvidersOauthCallbackError, GetProvidersOauthCallbackErrors, GetProvidersOauthCallbackResponse, GetProvidersOauthCallbackResponses, GetProvidersResponse, GetProvidersResponses, GetProviderTemplatesByIdData, GetProviderTemplatesByIdError, GetProviderTemplatesByIdErrors, GetProviderTemplatesByIdResponse, GetProviderTemplatesByIdResponses, GetProviderTemplatesData, GetProviderTemplatesError, GetProviderTemplatesErrors, GetProviderTemplatesResponse, GetProviderTemplatesResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponse, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersMetaResponses, GetSearchProvidersResponse, GetSearchProvidersResponses, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesError, GetSpeechModelsByIdCapabilitiesErrors, GetSpeechModelsByIdCapabilitiesResponse, GetSpeechModelsByIdCapabilitiesResponses, GetSpeechModelsByIdData, GetSpeechModelsByIdError, GetSpeechModelsByIdErrors, GetSpeechModelsByIdResponse, GetSpeechModelsByIdResponses, GetSpeechModelsData, GetSpeechModelsError, GetSpeechModelsErrors, GetSpeechModelsResponse, GetSpeechModelsResponses, GetSpeechProvidersByIdData, GetSpeechProvidersByIdError, GetSpeechProvidersByIdErrors, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsError, GetSpeechProvidersByIdModelsErrors, GetSpeechProvidersByIdModelsResponse, GetSpeechProvidersByIdModelsResponses, GetSpeechProvidersByIdResponse, GetSpeechProvidersByIdResponses, GetSpeechProvidersData, GetSpeechProvidersError, GetSpeechProvidersErrors, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponse, GetSpeechProvidersMetaResponses, GetSpeechProvidersResponse, GetSpeechProvidersResponses, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestError, GetSupermarketArtifactsIconByDigestErrors, GetSupermarketArtifactsIconByDigestResponses, GetSupermarketPackagesData, GetSupermarketPackagesError, GetSupermarketPackagesErrors, GetSupermarketPackagesResponse, GetSupermarketPackagesResponses, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesError, GetSupermarketRegistriesByRegistryIdCategoriesErrors, GetSupermarketRegistriesByRegistryIdCategoriesResponse, GetSupermarketRegistriesByRegistryIdCategoriesResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdError, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponse, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponses, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesError, GetSupermarketRegistriesByRegistryIdPackagesErrors, GetSupermarketRegistriesByRegistryIdPackagesResponse, GetSupermarketRegistriesByRegistryIdPackagesResponses, GetSupermarketRegistriesData, GetSupermarketRegistriesError, GetSupermarketRegistriesErrors, GetSupermarketRegistriesResponse, GetSupermarketRegistriesResponses, GetSupermarketSkillsData, GetSupermarketSkillsError, GetSupermarketSkillsErrors, GetSupermarketSkillsResponse, GetSupermarketSkillsResponses, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesError, GetTranscriptionModelsByIdCapabilitiesErrors, GetTranscriptionModelsByIdCapabilitiesResponse, GetTranscriptionModelsByIdCapabilitiesResponses, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdError, GetTranscriptionModelsByIdErrors, GetTranscriptionModelsByIdResponse, GetTranscriptionModelsByIdResponses, GetTranscriptionModelsData, GetTranscriptionModelsError, GetTranscriptionModelsErrors, GetTranscriptionModelsResponse, GetTranscriptionModelsResponses, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdError, GetTranscriptionProvidersByIdErrors, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsError, GetTranscriptionProvidersByIdModelsErrors, GetTranscriptionProvidersByIdModelsResponse, GetTranscriptionProvidersByIdModelsResponses, GetTranscriptionProvidersByIdResponse, GetTranscriptionProvidersByIdResponses, GetTranscriptionProvidersData, GetTranscriptionProvidersError, GetTranscriptionProvidersErrors, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponse, GetTranscriptionProvidersMetaResponses, GetTranscriptionProvidersResponse, GetTranscriptionProvidersResponses, GetUsersByIdData, GetUsersByIdError, GetUsersByIdErrors, GetUsersByIdResponse, GetUsersByIdResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesError, GetUsersMeChannelIdentitiesErrors, GetUsersMeChannelIdentitiesResponse, GetUsersMeChannelIdentitiesResponses, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponse, GetUsersMeChannelsByPlatformResponses, GetUsersMeComputerAccessData, GetUsersMeComputerAccessError, GetUsersMeComputerAccessErrors, GetUsersMeComputerAccessResponse, GetUsersMeComputerAccessResponses, GetUsersMeData, GetUsersMeError, GetUsersMeErrors, GetUsersMeResponse, GetUsersMeResponses, GetUsersMeRuntimesData, GetUsersMeRuntimesError, GetUsersMeRuntimesErrors, GetUsersMeRuntimesResponse, GetUsersMeRuntimesResponses, GetUsersResponse, GetUsersResponses, GetVideoModelsByIdData, GetVideoModelsByIdError, GetVideoModelsByIdErrors, GetVideoModelsByIdResponse, GetVideoModelsByIdResponses, GetVideoModelsData, GetVideoModelsError, GetVideoModelsErrors, GetVideoModelsResponse, GetVideoModelsResponses, GetVideoProvidersByIdData, GetVideoProvidersByIdError, GetVideoProvidersByIdErrors, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsError, GetVideoProvidersByIdModelsErrors, GetVideoProvidersByIdModelsResponse, GetVideoProvidersByIdModelsResponses, GetVideoProvidersByIdResponse, GetVideoProvidersByIdResponses, GetVideoProvidersData, GetVideoProvidersError, GetVideoProvidersErrors, GetVideoProvidersMetaData, GetVideoProvidersMetaResponse, GetVideoProvidersMetaResponses, GetVideoProvidersResponse, GetVideoProvidersResponses, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponse, GetWebhookTunnelStatusResponses, GithubComFelinicsMemohInternalMcpConnection, HandlersAcpRuntimeCreateRequest, HandlersAcpRuntimeModelRequest, HandlersAcpRuntimeModeRequest, HandlersAcpRuntimeReasoningRequest, HandlersAgentCredentialPutRequest, HandlersBatchDeleteRequest, HandlersBotUserCandidate, HandlersBotUserCandidateListResponse, HandlersBotUserGrantListResponse, HandlersBrowserSessionCreateRequest, HandlersBrowserSessionCreateResponse, HandlersBrowserSessionKeepAliveResponse, HandlersCacheStats, HandlersChannelMeta, HandlersCommandActionError, HandlersCommandActionListItem, HandlersCommandActionResult, HandlersCommandEventResponse, HandlersCompactionInfo, HandlersConnectorCredentialRequest, HandlersConnectorEnabledRequest, HandlersConnectorOAuthRequest, HandlersContainerCpuMetricsResponse, HandlersContainerGpuRequest, HandlersContainerMemoryMetricsResponse, HandlersContainerMetricsPayloadResponse, HandlersContainerMetricsStatusResponse, HandlersContainerResourceLimitCapabilitiesResponse, HandlersContainerResourceLimitCapabilityResponse, HandlersContainerResourceLimitObservedResponse, HandlersContainerResourceLimitValuesResponse, HandlersContainerStorageMetricsResponse, HandlersContextLifecycleAggregates, HandlersContextLifecycleResponse, HandlersContextLifecycleTurn, HandlersContextUsage, HandlersCreateContainerRequest, HandlersCreateContainerResponse, HandlersCreateSessionRequest, HandlersCreateSnapshotRequest, HandlersCreateSnapshotResponse, HandlersDailyTokenUsage, HandlersDisplayInfoResponse, HandlersDisplaySessionListResponse, HandlersDisplayWebRtcOfferRequest, HandlersDisplayWebRtcOfferResponse, HandlersEmailOAuthStatusResponse, HandlersEnqueueQueueRequest, HandlersErrorResponse, HandlersFollowUpQueueItemResponse, HandlersFollowUpQueueReorderRequest, HandlersFollowUpQueueResponse, HandlersForkSessionRequest, HandlersFsArchiveRequest, HandlersFsDeleteRequest, HandlersFsExtractRequest, HandlersFsExtractResponse, HandlersFsFileInfo, HandlersFsListResponse, HandlersFsMkdirRequest, HandlersFsOpResponse, HandlersFsReadResponse, HandlersFsRenameRequest, HandlersFsUploadResponse, HandlersFsWriteRequest, HandlersGetContainerMetricsResponse, HandlersGetContainerResourceLimitsResponse, HandlersGetContainerResponse, HandlersGraphEdge, HandlersGraphNode, HandlersGraphResponse, HandlersHookEventInfo, HandlersHooksEventsResponse, HandlersHookTestRequest, HandlersHookTestResponse, HandlersInstallPackageRequest, HandlersInstallRegistryPackageResponse, HandlersInstallRegistrySkillResponse, HandlersListSessionsResponse, HandlersListSnapshotsResponse, HandlersLocalChannelMessageRequest, HandlersLoginRequest, HandlersLoginResponse, HandlersMcpStdioRequest, HandlersMcpStdioResponse, HandlersMemoryAddPayload, HandlersMemoryCompactPayload, HandlersMemoryDeletePayload, HandlersMemorySearchPayload, HandlersMemoryUpdatePayload, HandlersModelPreferenceSeedResponse, HandlersModelTokenUsage, HandlersOauthAuthorizeRequest, HandlersOauthDiscoverRequest, HandlersOauthExchangeRequest, HandlersPingResponse, HandlersProbeResponse, HandlersQuickActionExecuteRequest, HandlersRefreshResponse, HandlersRollbackRequest, HandlersSafeSkillsResponse, HandlersSessionInfoResponse, HandlersSessionQueueResponse, HandlersSkillItem, HandlersSkillsActionRequest, HandlersSkillsDeleteRequest, HandlersSkillsOpResponse, HandlersSkillsResponse, HandlersSkillsUpsertRequest, HandlersSnapshotInfo, HandlersSteerQueueItemResponse, HandlersSteerQueueReorderRequest, HandlersSteerQueueResponse, HandlersSupermarketAuthor, HandlersSupermarketCatalogSkill, HandlersSupermarketCatalogSkillListResponse, HandlersSupermarketRegistry, HandlersSupermarketRegistryListResponse, HandlersSupermarketSkillArtifact, HandlersSupermarketSkillCategory, HandlersSupermarketSkillCategoryListResponse, HandlersSupermarketSkillCategoryRegistry, HandlersSupermarketSkillIcon, HandlersSupermarketSkillIconAsset, HandlersSupermarketSkillPackageCategory, HandlersSupermarketSkillPackageDescriptor, HandlersSupermarketSkillPackageListResponse, HandlersSupermarketSkillPackageSummary, HandlersSupermarketSkillSource, HandlersSynthesizeRequest, HandlersSynthesizeResponse, HandlersTerminalInfoResponse, HandlersTokenUsageRecord, HandlersTokenUsageRecordsResponse, HandlersTokenUsageResponse, HandlersToolApprovalDecisionRequest, HandlersToolDefBucket, HandlersTriggerCompactResponse, HandlersUiLocateMessageResponse, HandlersUiMessageListResponse, HandlersUpdateContainerMetricsRequest, HandlersUpdateContainerResourceLimitsRequest, HandlersUpdateQueueRequest, HandlersUpdateSessionRequest, HooksActionResult, HooksOutputWarning, HooksResult, HooksSystemSectionCache, HooksSystemSectionOutput, HooksSystemSectionRetention, HooksToolPayload, McpAuthorizeResult, McpDiscoveryResult, McpExportResponse, McpImportRequest, McpListResponse, McpMcpServerEntry, McpOAuthStatus, McpToolDescriptor, McpUpsertRequest, ModelsAddRequest, ModelsAddResponse, ModelsCountResponse, ModelsGetResponse, ModelsModelConfig, ModelsModelType, ModelsTestResponse, ModelsTestStatus, ModelsUpdateRequest, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelError, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningError, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponse, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponses, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdError, PatchBotsByBotIdAgentsByIdErrors, PatchBotsByBotIdAgentsByIdResponse, PatchBotsByBotIdAgentsByIdResponses, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdError, PatchBotsByBotIdConnectorsByConnectionIdErrors, PatchBotsByBotIdConnectorsByConnectionIdResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningError, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponse, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponses, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdError, PatchBotsByBotIdSessionsBySessionIdErrors, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponse, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses, PatchBotsByBotIdSessionsBySessionIdResponse, PatchBotsByBotIdSessionsBySessionIdResponses, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponse, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdError, PatchBotsByBotIdWorkdirsByWorkdirIdErrors, PatchBotsByBotIdWorkdirsByWorkdirIdResponse, PatchBotsByBotIdWorkdirsByWorkdirIdResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponse, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostBotsBackupImportData, PostBotsBackupImportError, PostBotsBackupImportErrors, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewError, PostBotsBackupImportPreviewErrors, PostBotsBackupImportPreviewResponse, PostBotsBackupImportPreviewResponses, PostBotsBackupImportResponse, PostBotsBackupImportResponses, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesError, PostBotsByBotIdAclRulesErrors, PostBotsByBotIdAclRulesResponse, PostBotsByBotIdAclRulesResponses, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesError, PostBotsByBotIdAcpRuntimesErrors, PostBotsByBotIdAcpRuntimesResponse, PostBotsByBotIdAcpRuntimesResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeError, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponse, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelError, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelResponses, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollError, PostBotsByBotIdAgentsByIdCodexLoginDevicePollErrors, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponse, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponses, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsError, PostBotsByBotIdAgentsErrors, PostBotsByBotIdAgentsResponse, PostBotsByBotIdAgentsResponses, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportError, PostBotsByBotIdBackupExportErrors, PostBotsByBotIdBackupExportResponses, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersError, PostBotsByBotIdChannelManagersErrors, PostBotsByBotIdChannelManagersResponses, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyError, PostBotsByBotIdConnectorsApiKeyErrors, PostBotsByBotIdConnectorsApiKeyResponse, PostBotsByBotIdConnectorsApiKeyResponses, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthError, PostBotsByBotIdConnectorsByConnectionIdReauthErrors, PostBotsByBotIdConnectorsByConnectionIdReauthResponse, PostBotsByBotIdConnectorsByConnectionIdReauthResponses, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthError, PostBotsByBotIdConnectorsOauthErrors, PostBotsByBotIdConnectorsOauthResponse, PostBotsByBotIdConnectorsOauthResponses, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveError, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveErrors, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponse, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponses, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsError, PostBotsByBotIdContainerBrowserSessionsErrors, PostBotsByBotIdContainerBrowserSessionsResponse, PostBotsByBotIdContainerBrowserSessionsResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreError, PostBotsByBotIdContainerDataRestoreErrors, PostBotsByBotIdContainerDataRestoreResponse, PostBotsByBotIdContainerDataRestoreResponses, PostBotsByBotIdContainerDisplayPrepareData, PostBotsByBotIdContainerDisplayPrepareError, PostBotsByBotIdContainerDisplayPrepareErrors, PostBotsByBotIdContainerDisplayPrepareResponse, PostBotsByBotIdContainerDisplayPrepareResponses, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferError, PostBotsByBotIdContainerDisplayWebrtcOfferErrors, PostBotsByBotIdContainerDisplayWebrtcOfferResponse, PostBotsByBotIdContainerDisplayWebrtcOfferResponses, PostBotsByBotIdContainerError, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveError, PostBotsByBotIdContainerFsArchiveErrors, PostBotsByBotIdContainerFsArchiveResponses, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteError, PostBotsByBotIdContainerFsDeleteErrors, PostBotsByBotIdContainerFsDeleteResponse, PostBotsByBotIdContainerFsDeleteResponses, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractError, PostBotsByBotIdContainerFsExtractErrors, PostBotsByBotIdContainerFsExtractResponse, PostBotsByBotIdContainerFsExtractResponses, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirError, PostBotsByBotIdContainerFsMkdirErrors, PostBotsByBotIdContainerFsMkdirResponse, PostBotsByBotIdContainerFsMkdirResponses, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameError, PostBotsByBotIdContainerFsRenameErrors, PostBotsByBotIdContainerFsRenameResponse, PostBotsByBotIdContainerFsRenameResponses, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadError, PostBotsByBotIdContainerFsUploadErrors, PostBotsByBotIdContainerFsUploadResponse, PostBotsByBotIdContainerFsUploadResponses, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteError, PostBotsByBotIdContainerFsWriteErrors, PostBotsByBotIdContainerFsWriteResponse, PostBotsByBotIdContainerFsWriteResponses, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsError, PostBotsByBotIdContainerSkillsActionsErrors, PostBotsByBotIdContainerSkillsActionsResponse, PostBotsByBotIdContainerSkillsActionsResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackError, PostBotsByBotIdContainerSnapshotsRollbackErrors, PostBotsByBotIdContainerSnapshotsRollbackResponse, PostBotsByBotIdContainerSnapshotsRollbackResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsError, PostBotsByBotIdEmailBindingsErrors, PostBotsByBotIdEmailBindingsResponse, PostBotsByBotIdEmailBindingsResponses, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestError, PostBotsByBotIdHooksTestErrors, PostBotsByBotIdHooksTestResponse, PostBotsByBotIdHooksTestResponses, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeError, PostBotsByBotIdMcpByIdOauthAuthorizeErrors, PostBotsByBotIdMcpByIdOauthAuthorizeResponse, PostBotsByBotIdMcpByIdOauthAuthorizeResponses, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverError, PostBotsByBotIdMcpByIdOauthDiscoverErrors, PostBotsByBotIdMcpByIdOauthDiscoverResponse, PostBotsByBotIdMcpByIdOauthDiscoverResponses, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeError, PostBotsByBotIdMcpByIdOauthExchangeErrors, PostBotsByBotIdMcpByIdOauthExchangeResponse, PostBotsByBotIdMcpByIdOauthExchangeResponses, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeError, PostBotsByBotIdMcpByIdProbeErrors, PostBotsByBotIdMcpByIdProbeResponse, PostBotsByBotIdMcpByIdProbeResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestError, PostBotsByBotIdMemoryIngestErrors, PostBotsByBotIdMemoryIngestResponse, PostBotsByBotIdMemoryIngestResponses, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteError, PostBotsByBotIdQuickActionsExecuteErrors, PostBotsByBotIdQuickActionsExecuteResponse, PostBotsByBotIdQuickActionsExecuteResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponse, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeError, PostBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponse, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactError, PostBotsByBotIdSessionsBySessionIdCompactErrors, PostBotsByBotIdSessionsBySessionIdCompactResponse, PostBotsByBotIdSessionsBySessionIdCompactResponses, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerError, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerErrors, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponse, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponses, PostBotsByBotIdSessionsBySessionIdFollowUpQueueData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueError, PostBotsByBotIdSessionsBySessionIdFollowUpQueueErrors, PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponse, PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponses, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkError, PostBotsByBotIdSessionsBySessionIdForkErrors, PostBotsByBotIdSessionsBySessionIdForkResponse, PostBotsByBotIdSessionsBySessionIdForkResponses, PostBotsByBotIdSessionsBySessionIdSteerQueueData, PostBotsByBotIdSessionsBySessionIdSteerQueueError, PostBotsByBotIdSessionsBySessionIdSteerQueueErrors, PostBotsByBotIdSessionsBySessionIdSteerQueueResponse, PostBotsByBotIdSessionsBySessionIdSteerQueueResponses, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsError, PostBotsByBotIdSessionsErrors, PostBotsByBotIdSessionsResponse, PostBotsByBotIdSessionsResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageError, PostBotsByBotIdSupermarketInstallPackageErrors, PostBotsByBotIdSupermarketInstallPackageResponse, PostBotsByBotIdSupermarketInstallPackageResponses, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveError, PostBotsByBotIdToolApprovalsByApprovalIdApproveErrors, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponse, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponses, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectError, PostBotsByBotIdToolApprovalsByApprovalIdRejectErrors, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponse, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponse, PostBotsByBotIdToolsResponses, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeError, PostBotsByBotIdTtsSynthesizeErrors, PostBotsByBotIdTtsSynthesizeResponse, PostBotsByBotIdTtsSynthesizeResponses, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessError, PostBotsByBotIdUserAccessErrors, PostBotsByBotIdUserAccessResponse, PostBotsByBotIdUserAccessResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponse, PostBotsByBotIdWebMessagesResponses, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsError, PostBotsByBotIdWorkdirsErrors, PostBotsByBotIdWorkdirsResponse, PostBotsByBotIdWorkdirsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformSendResponses, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointError, PostBotsByIdChannelByPlatformWebhookEndpointErrors, PostBotsByIdChannelByPlatformWebhookEndpointResponse, PostBotsByIdChannelByPlatformWebhookEndpointResponses, PostBotsData, PostBotsError, PostBotsErrors, PostBotsResponse, PostBotsResponses, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdError, PostEmailMailgunWebhookByConfigIdErrors, PostEmailMailgunWebhookByConfigIdResponse, PostEmailMailgunWebhookByConfigIdResponses, PostEmailProvidersData, PostEmailProvidersError, PostEmailProvidersErrors, PostEmailProvidersResponse, PostEmailProvidersResponses, PostFetchProvidersData, PostFetchProvidersError, PostFetchProvidersErrors, PostFetchProvidersResponse, PostFetchProvidersResponses, PostMemoryProvidersData, PostMemoryProvidersError, PostMemoryProvidersErrors, PostMemoryProvidersResponse, PostMemoryProvidersResponses, PostModelsByIdTestData, PostModelsByIdTestError, PostModelsByIdTestErrors, PostModelsByIdTestResponse, PostModelsByIdTestResponses, PostModelsData, PostModelsError, PostModelsErrors, PostModelsResponse, PostModelsResponses, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsError, PostProvidersByIdImportModelsErrors, PostProvidersByIdImportModelsResponse, PostProvidersByIdImportModelsResponses, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollError, PostProvidersByIdOauthPollErrors, PostProvidersByIdOauthPollResponse, PostProvidersByIdOauthPollResponses, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestErrors, PostProvidersByIdTestResponse, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersError, PostProvidersErrors, PostProvidersFromTemplateData, PostProvidersFromTemplateError, PostProvidersFromTemplateErrors, PostProvidersFromTemplateResponse, PostProvidersFromTemplateResponses, PostProvidersResponse, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersErrors, PostSearchProvidersResponse, PostSearchProvidersResponses, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestError, PostSpeechModelsByIdTestErrors, PostSpeechModelsByIdTestResponses, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsError, PostSpeechProvidersByIdImportModelsErrors, PostSpeechProvidersByIdImportModelsResponse, PostSpeechProvidersByIdImportModelsResponses, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestError, PostTranscriptionModelsByIdTestErrors, PostTranscriptionModelsByIdTestResponse, PostTranscriptionModelsByIdTestResponses, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsError, PostTranscriptionProvidersByIdImportModelsErrors, PostTranscriptionProvidersByIdImportModelsResponse, PostTranscriptionProvidersByIdImportModelsResponses, PostUsersData, PostUsersError, PostUsersErrors, PostUsersMeChannelLinksData, PostUsersMeChannelLinksError, PostUsersMeChannelLinksErrors, PostUsersMeChannelLinksResponse, PostUsersMeChannelLinksResponses, PostUsersMeRuntimesData, PostUsersMeRuntimesError, PostUsersMeRuntimesErrors, PostUsersMeRuntimesResponse, PostUsersMeRuntimesResponses, PostUsersResponse, PostUsersResponses, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsError, PostVideoProvidersByIdImportModelsErrors, PostVideoProvidersByIdImportModelsResponse, PostVideoProvidersByIdImportModelsResponses, ProvidersCountResponse, ProvidersCreateFromTemplateRequest, ProvidersCreateRequest, ProvidersGetResponse, ProvidersImportModelsRequest, ProvidersImportModelsResponse, ProvidersOAuthAccount, ProvidersOAuthAuthorizeResponse, ProvidersOAuthDeviceStatus, ProvidersOAuthStatus, ProvidersTestResponse, ProvidersTestStatus, ProvidersUpdateRequest, ProvidertemplatesGetResponse, ProvidertemplatesModelResponse, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectError, PutBotsByBotIdAclDefaultEffectErrors, PutBotsByBotIdAclDefaultEffectResponses, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdError, PutBotsByBotIdAclRulesByRuleIdErrors, PutBotsByBotIdAclRulesByRuleIdResponse, PutBotsByBotIdAclRulesByRuleIdResponses, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialError, PutBotsByBotIdAgentsByIdCredentialErrors, PutBotsByBotIdAgentsByIdCredentialResponse, PutBotsByBotIdAgentsByIdCredentialResponses, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsError, PutBotsByBotIdContainerMetricsErrors, PutBotsByBotIdContainerMetricsResponse, PutBotsByBotIdContainerMetricsResponses, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdError, PutBotsByBotIdEmailBindingsByIdErrors, PutBotsByBotIdEmailBindingsByIdResponse, PutBotsByBotIdEmailBindingsByIdResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportError, PutBotsByBotIdMcpOpsImportErrors, PutBotsByBotIdMcpOpsImportResponse, PutBotsByBotIdMcpOpsImportResponses, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdError, PutBotsByBotIdMemoryByMemoryIdErrors, PutBotsByBotIdMemoryByMemoryIdResponse, PutBotsByBotIdMemoryByMemoryIdResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderData, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderError, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderErrors, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponse, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponses, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderData, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderError, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderErrors, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponse, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSettingsResponses, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdError, PutBotsByBotIdUserAccessByGrantIdErrors, PutBotsByBotIdUserAccessByGrantIdResponse, PutBotsByBotIdUserAccessByGrantIdResponses, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalError, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalErrors, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalResponses, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryError, PutBotsByBotIdWorkspaceTargetsPrimaryErrors, PutBotsByBotIdWorkspaceTargetsPrimaryResponses, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdError, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdErrors, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponse, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponse, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdError, PutBotsByIdErrors, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponse, PutBotsByIdOwnerResponses, PutBotsByIdResponse, PutBotsByIdResponses, PutEmailProvidersByIdData, PutEmailProvidersByIdError, PutEmailProvidersByIdErrors, PutEmailProvidersByIdResponse, PutEmailProvidersByIdResponses, PutFetchProvidersByIdData, PutFetchProvidersByIdError, PutFetchProvidersByIdErrors, PutFetchProvidersByIdResponse, PutFetchProvidersByIdResponses, PutMemoryProvidersByIdData, PutMemoryProvidersByIdError, PutMemoryProvidersByIdErrors, PutMemoryProvidersByIdResponse, PutMemoryProvidersByIdResponses, PutModelsByIdData, PutModelsByIdError, PutModelsByIdErrors, PutModelsByIdResponse, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponse, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdErrors, PutProvidersByIdResponse, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponse, PutSearchProvidersByIdResponses, PutSpeechModelsByIdData, PutSpeechModelsByIdError, PutSpeechModelsByIdErrors, PutSpeechModelsByIdResponse, PutSpeechModelsByIdResponses, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdError, PutTranscriptionModelsByIdErrors, PutTranscriptionModelsByIdResponse, PutTranscriptionModelsByIdResponses, PutUsersByIdData, PutUsersByIdError, PutUsersByIdErrors, PutUsersByIdResponse, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponse, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeError, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponse, PutUsersMeResponses, PutVideoModelsByIdData, PutVideoModelsByIdError, PutVideoModelsByIdErrors, PutVideoModelsByIdResponse, PutVideoModelsByIdResponses, ReasoningOptions, ScheduleCreateRequest, ScheduleExecutionConfig, ScheduleListLogsResponse, ScheduleListResponse, ScheduleLog, ScheduleNullableInt, ScheduleSchedule, ScheduleUpdateRequest, SearchprovidersCreateRequest, SearchprovidersGetResponse, SearchprovidersProviderConfigSchema, SearchprovidersProviderFieldSchema, SearchprovidersProviderMeta, SearchprovidersProviderName, SearchprovidersUpdateRequest, SessionruntimeFollowUpPendingRef, SessionruntimeQueueStatus, SessionruntimeSteerPendingRef, SessionSession, SettingsSettings, SettingsToolApprovalConfig, SettingsToolApprovalExecPolicy, SettingsToolApprovalFilePolicy, SettingsToolApprovalMode, SettingsUpsertRequest, SkillpackagesInstallation, SkillsSafeCatalogItem, SupermarketUninstallPackageResponse, UserinputUiAnswer, UserinputUiOption, UserinputUiQuestion, UserruntimeCreateRuntimeRequest, UserruntimeRuntime, VideoConfigSchema, VideoFieldSchema, VideoImportModelsResponse, VideoModelInfo, VideoModelResponse, VideoProviderMetaResponse, VideoProviderResponse, VideoUpdateModelRequest, WebhooktunnelStatus, WorkdirCreateRequest, WorkdirUpdateRequest, WorkdirWorkdir, WorkdirWorkdirsResponse, WorkspaceSetPrimaryWorkspaceTargetRequest, WorkspaceUpdateWorkspaceTargetToolApprovalRequest, WorkspaceWorkspaceTarget, WorkspaceWorkspaceTargetGrant, WorkspaceWorkspaceTargetGrantsResponse, WorkspaceWorkspaceTargetsResponse, WorkspaceWorkspaceTargetToolApproval } from './types.gen'; diff --git a/packages/sdk/src/sdk.gen.ts b/packages/sdk/src/sdk.gen.ts index 64c7b3523..49db8aa8d 100644 --- a/packages/sdk/src/sdk.gen.ts +++ b/packages/sdk/src/sdk.gen.ts @@ -3,7 +3,7 @@ import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type ServerSentEventsResult, type TDataShape } from './client'; import { client } from './client.gen'; import { getBotsByBotIdMessagesLocateResponseTransformer, getBotsByBotIdMessagesResponseTransformer } from './transformers.gen'; -import type { DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdErrors, DeleteBotsByBotIdAclRulesByRuleIdResponses, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdErrors, DeleteBotsByBotIdAcpRuntimesByRuntimeIdResponses, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialErrors, DeleteBotsByBotIdAgentsByIdCredentialResponses, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdErrors, DeleteBotsByBotIdAgentsByIdResponses, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdErrors, DeleteBotsByBotIdChannelManagersByChannelIdentityIdResponses, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsErrors, DeleteBotsByBotIdCompactionLogsResponses, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdErrors, DeleteBotsByBotIdConnectorsByConnectionIdResponses, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdErrors, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdResponses, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdErrors, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdResponses, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdErrors, DeleteBotsByBotIdEmailBindingsByIdResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenErrors, DeleteBotsByBotIdMcpByIdOauthTokenResponses, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesErrors, DeleteBotsByBotIdMessagesResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsErrors, DeleteBotsByBotIdScheduleLogsResponses, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdErrors, DeleteBotsByBotIdSessionsBySessionIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdErrors, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponses, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdErrors, DeleteBotsByBotIdUserAccessByGrantIdResponses, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdErrors, DeleteBotsByBotIdWorkdirsByWorkdirIdResponses, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors, DeleteBotsByBotIdWorkspaceTargetsByTargetIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdErrors, DeleteBotsByIdResponses, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdErrors, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenErrors, DeleteEmailProvidersByIdOauthTokenResponses, DeleteEmailProvidersByIdResponses, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdErrors, DeleteFetchProvidersByIdResponses, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdErrors, DeleteMemoryProvidersByIdResponses, DeleteModelsByIdData, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdErrors, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenErrors, DeleteProvidersByIdOauthTokenResponses, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, DeleteUsersByIdData, DeleteUsersByIdErrors, DeleteUsersByIdResponses, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdErrors, DeleteUsersMeChannelIdentitiesByChannelIdentityIdResponses, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdErrors, DeleteUsersMeRuntimesByIdResponses, GetAcpProfilesData, GetAcpProfilesResponses, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesErrors, GetBotsByBotIdAclChannelIdentitiesResponses, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectErrors, GetBotsByBotIdAclDefaultEffectResponses, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesErrors, GetBotsByBotIdAclRulesResponses, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdErrors, GetBotsByBotIdAcpRuntimesByRuntimeIdResponses, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialErrors, GetBotsByBotIdAgentsByIdCredentialResponses, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdErrors, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsErrors, GetBotsByBotIdAgentsByIdModelsResponses, GetBotsByBotIdAgentsByIdResponses, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsErrors, GetBotsByBotIdAgentsResponses, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryErrors, GetBotsByBotIdBackupSummaryResponses, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersErrors, GetBotsByBotIdChannelManagersResponses, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsErrors, GetBotsByBotIdCompactionLogsResponses, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdErrors, GetBotsByBotIdConnectorsByConnectionIdResponses, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsErrors, GetBotsByBotIdConnectorsResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayErrors, GetBotsByBotIdContainerDisplayResponses, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsErrors, GetBotsByBotIdContainerDisplaySessionsResponses, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadErrors, GetBotsByBotIdContainerFsDownloadResponses, GetBotsByBotIdContainerFsErrors, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListErrors, GetBotsByBotIdContainerFsListResponses, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadErrors, GetBotsByBotIdContainerFsReadResponses, GetBotsByBotIdContainerFsResponses, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsErrors, GetBotsByBotIdContainerMetricsResponses, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsErrors, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalErrors, GetBotsByBotIdContainerTerminalResponses, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsErrors, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsErrors, GetBotsByBotIdEmailBindingsResponses, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdErrors, GetBotsByBotIdEmailOutboxByIdResponses, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxErrors, GetBotsByBotIdEmailOutboxResponses, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsErrors, GetBotsByBotIdHooksEventsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusErrors, GetBotsByBotIdMcpByIdOauthStatusResponses, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportErrors, GetBotsByBotIdMcpOpsExportResponses, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphErrors, GetBotsByBotIdMemoryGraphResponses, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusErrors, GetBotsByBotIdMemoryStatusResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateErrors, GetBotsByBotIdMessagesLocateResponses, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsErrors, GetBotsByBotIdScheduleByIdLogsResponses, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsErrors, GetBotsByBotIdScheduleLogsResponses, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleErrors, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdErrors, GetBotsByBotIdSessionsBySessionIdResponses, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusErrors, GetBotsByBotIdSessionsBySessionIdStatusResponses, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsErrors, GetBotsByBotIdSessionsEventsData, GetBotsByBotIdSessionsEventsErrors, GetBotsByBotIdSessionsEventsResponse, GetBotsByBotIdSessionsEventsResponses, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedErrors, GetBotsByBotIdSessionsModelPreferenceSeedResponses, GetBotsByBotIdSessionsResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogErrors, GetBotsByBotIdSkillsCatalogResponses, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesErrors, GetBotsByBotIdSupermarketPackagesResponses, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageErrors, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsErrors, GetBotsByBotIdTokenUsageRecordsResponses, GetBotsByBotIdTokenUsageResponses, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesErrors, GetBotsByBotIdUserAccessCandidatesResponses, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessErrors, GetBotsByBotIdUserAccessResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponse, GetBotsByBotIdWebStreamResponses, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsErrors, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsErrors, GetBotsByBotIdWorkdirsResponses, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsErrors, GetBotsByBotIdWorkspaceTargetsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksErrors, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdErrors, GetBotsByIdResponses, GetBotsData, GetBotsErrors, GetBotsNameAvailabilityData, GetBotsNameAvailabilityErrors, GetBotsNameAvailabilityResponses, GetBotsResponses, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesErrors, GetBotsUserAccessCandidatesResponses, GetChannelsByPlatformData, GetChannelsByPlatformErrors, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsErrors, GetChannelsResponses, GetConnectorsCatalogData, GetConnectorsCatalogErrors, GetConnectorsCatalogResponses, GetEmailOauthCallbackData, GetEmailOauthCallbackErrors, GetEmailOauthCallbackResponses, GetEmailProvidersByIdData, GetEmailProvidersByIdErrors, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeErrors, GetEmailProvidersByIdOauthAuthorizeResponses, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusErrors, GetEmailProvidersByIdOauthStatusResponses, GetEmailProvidersByIdResponses, GetEmailProvidersData, GetEmailProvidersErrors, GetEmailProvidersMetaData, GetEmailProvidersMetaResponses, GetEmailProvidersResponses, GetFetchProvidersByIdData, GetFetchProvidersByIdErrors, GetFetchProvidersByIdResponses, GetFetchProvidersData, GetFetchProvidersErrors, GetFetchProvidersMetaData, GetFetchProvidersMetaResponses, GetFetchProvidersResponses, GetMemoryProvidersByIdData, GetMemoryProvidersByIdErrors, GetMemoryProvidersByIdResponses, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusErrors, GetMemoryProvidersByIdStatusResponses, GetMemoryProvidersData, GetMemoryProvidersErrors, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponses, GetMemoryProvidersResponses, GetModelsByIdData, GetModelsByIdErrors, GetModelsByIdResponses, GetModelsCountData, GetModelsCountErrors, GetModelsCountResponses, GetModelsData, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponses, GetModelsResponses, GetOauthMcpCallbackData, GetOauthMcpCallbackErrors, GetOauthMcpCallbackResponses, GetPingData, GetPingResponses, GetProvidersByIdData, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponses, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeErrors, GetProvidersByIdOauthAuthorizeResponses, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusErrors, GetProvidersByIdOauthStatusResponses, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountErrors, GetProvidersCountResponses, GetProvidersData, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameErrors, GetProvidersNameByNameResponses, GetProvidersOauthCallbackData, GetProvidersOauthCallbackErrors, GetProvidersOauthCallbackResponses, GetProvidersResponses, GetProviderTemplatesByIdData, GetProviderTemplatesByIdErrors, GetProviderTemplatesByIdResponses, GetProviderTemplatesData, GetProviderTemplatesErrors, GetProviderTemplatesResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponses, GetSearchProvidersResponses, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesErrors, GetSpeechModelsByIdCapabilitiesResponses, GetSpeechModelsByIdData, GetSpeechModelsByIdErrors, GetSpeechModelsByIdResponses, GetSpeechModelsData, GetSpeechModelsErrors, GetSpeechModelsResponses, GetSpeechProvidersByIdData, GetSpeechProvidersByIdErrors, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsErrors, GetSpeechProvidersByIdModelsResponses, GetSpeechProvidersByIdResponses, GetSpeechProvidersData, GetSpeechProvidersErrors, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponses, GetSpeechProvidersResponses, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestErrors, GetSupermarketArtifactsIconByDigestResponses, GetSupermarketPackagesData, GetSupermarketPackagesErrors, GetSupermarketPackagesResponses, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesErrors, GetSupermarketRegistriesByRegistryIdCategoriesResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponses, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesErrors, GetSupermarketRegistriesByRegistryIdPackagesResponses, GetSupermarketRegistriesData, GetSupermarketRegistriesErrors, GetSupermarketRegistriesResponses, GetSupermarketSkillsData, GetSupermarketSkillsErrors, GetSupermarketSkillsResponses, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesErrors, GetTranscriptionModelsByIdCapabilitiesResponses, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdErrors, GetTranscriptionModelsByIdResponses, GetTranscriptionModelsData, GetTranscriptionModelsErrors, GetTranscriptionModelsResponses, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdErrors, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsErrors, GetTranscriptionProvidersByIdModelsResponses, GetTranscriptionProvidersByIdResponses, GetTranscriptionProvidersData, GetTranscriptionProvidersErrors, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponses, GetTranscriptionProvidersResponses, GetUsersByIdData, GetUsersByIdErrors, GetUsersByIdResponses, GetUsersData, GetUsersErrors, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesErrors, GetUsersMeChannelIdentitiesResponses, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponses, GetUsersMeComputerAccessData, GetUsersMeComputerAccessErrors, GetUsersMeComputerAccessResponses, GetUsersMeData, GetUsersMeErrors, GetUsersMeResponses, GetUsersMeRuntimesData, GetUsersMeRuntimesErrors, GetUsersMeRuntimesResponses, GetUsersResponses, GetVideoModelsByIdData, GetVideoModelsByIdErrors, GetVideoModelsByIdResponses, GetVideoModelsData, GetVideoModelsErrors, GetVideoModelsResponses, GetVideoProvidersByIdData, GetVideoProvidersByIdErrors, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsErrors, GetVideoProvidersByIdModelsResponses, GetVideoProvidersByIdResponses, GetVideoProvidersData, GetVideoProvidersErrors, GetVideoProvidersMetaData, GetVideoProvidersMetaResponses, GetVideoProvidersResponses, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponses, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdErrors, PatchBotsByBotIdAgentsByIdResponses, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdErrors, PatchBotsByBotIdConnectorsByConnectionIdResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponses, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdErrors, PatchBotsByBotIdSessionsBySessionIdResponses, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdErrors, PatchBotsByBotIdWorkdirsByWorkdirIdResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostBotsBackupImportData, PostBotsBackupImportErrors, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewErrors, PostBotsBackupImportPreviewResponses, PostBotsBackupImportResponses, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesErrors, PostBotsByBotIdAclRulesResponses, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesErrors, PostBotsByBotIdAcpRuntimesResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelResponses, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollErrors, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponses, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsErrors, PostBotsByBotIdAgentsResponses, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportErrors, PostBotsByBotIdBackupExportResponses, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersErrors, PostBotsByBotIdChannelManagersResponses, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyErrors, PostBotsByBotIdConnectorsApiKeyResponses, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthErrors, PostBotsByBotIdConnectorsByConnectionIdReauthResponses, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthErrors, PostBotsByBotIdConnectorsOauthResponses, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveErrors, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponses, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsErrors, PostBotsByBotIdContainerBrowserSessionsResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreErrors, PostBotsByBotIdContainerDataRestoreResponses, PostBotsByBotIdContainerDisplayPrepareData, PostBotsByBotIdContainerDisplayPrepareErrors, PostBotsByBotIdContainerDisplayPrepareResponse, PostBotsByBotIdContainerDisplayPrepareResponses, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferErrors, PostBotsByBotIdContainerDisplayWebrtcOfferResponses, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveErrors, PostBotsByBotIdContainerFsArchiveResponses, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteErrors, PostBotsByBotIdContainerFsDeleteResponses, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractErrors, PostBotsByBotIdContainerFsExtractResponses, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirErrors, PostBotsByBotIdContainerFsMkdirResponses, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameErrors, PostBotsByBotIdContainerFsRenameResponses, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadErrors, PostBotsByBotIdContainerFsUploadResponses, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteErrors, PostBotsByBotIdContainerFsWriteResponses, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsErrors, PostBotsByBotIdContainerSkillsActionsResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackErrors, PostBotsByBotIdContainerSnapshotsRollbackResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsErrors, PostBotsByBotIdEmailBindingsResponses, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestErrors, PostBotsByBotIdHooksTestResponses, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeErrors, PostBotsByBotIdMcpByIdOauthAuthorizeResponses, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverErrors, PostBotsByBotIdMcpByIdOauthDiscoverResponses, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeErrors, PostBotsByBotIdMcpByIdOauthExchangeResponses, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeErrors, PostBotsByBotIdMcpByIdProbeResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestErrors, PostBotsByBotIdMemoryIngestResponses, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteErrors, PostBotsByBotIdQuickActionsExecuteResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactErrors, PostBotsByBotIdSessionsBySessionIdCompactResponses, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkErrors, PostBotsByBotIdSessionsBySessionIdForkResponses, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsErrors, PostBotsByBotIdSessionsResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageErrors, PostBotsByBotIdSupermarketInstallPackageResponses, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveErrors, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponses, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectErrors, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponses, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeErrors, PostBotsByBotIdTtsSynthesizeResponses, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessErrors, PostBotsByBotIdUserAccessResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponses, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsErrors, PostBotsByBotIdWorkdirsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponses, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointErrors, PostBotsByIdChannelByPlatformWebhookEndpointResponses, PostBotsData, PostBotsErrors, PostBotsResponses, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdErrors, PostEmailMailgunWebhookByConfigIdResponses, PostEmailProvidersData, PostEmailProvidersErrors, PostEmailProvidersResponses, PostFetchProvidersData, PostFetchProvidersErrors, PostFetchProvidersResponses, PostMemoryProvidersData, PostMemoryProvidersErrors, PostMemoryProvidersResponses, PostModelsByIdTestData, PostModelsByIdTestErrors, PostModelsByIdTestResponses, PostModelsData, PostModelsErrors, PostModelsResponses, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsErrors, PostProvidersByIdImportModelsResponses, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollErrors, PostProvidersByIdOauthPollResponses, PostProvidersByIdTestData, PostProvidersByIdTestErrors, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersErrors, PostProvidersFromTemplateData, PostProvidersFromTemplateErrors, PostProvidersFromTemplateResponses, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersErrors, PostSearchProvidersResponses, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestErrors, PostSpeechModelsByIdTestResponses, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsErrors, PostSpeechProvidersByIdImportModelsResponses, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestErrors, PostTranscriptionModelsByIdTestResponses, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsErrors, PostTranscriptionProvidersByIdImportModelsResponses, PostUsersData, PostUsersErrors, PostUsersMeChannelLinksData, PostUsersMeChannelLinksErrors, PostUsersMeChannelLinksResponses, PostUsersMeRuntimesData, PostUsersMeRuntimesErrors, PostUsersMeRuntimesResponses, PostUsersResponses, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsErrors, PostVideoProvidersByIdImportModelsResponses, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectErrors, PutBotsByBotIdAclDefaultEffectResponses, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdErrors, PutBotsByBotIdAclRulesByRuleIdResponses, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialErrors, PutBotsByBotIdAgentsByIdCredentialResponses, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsErrors, PutBotsByBotIdContainerMetricsResponses, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdErrors, PutBotsByBotIdEmailBindingsByIdResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportErrors, PutBotsByBotIdMcpOpsImportResponses, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdErrors, PutBotsByBotIdMemoryByMemoryIdResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponses, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdErrors, PutBotsByBotIdUserAccessByGrantIdResponses, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalErrors, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalResponses, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryErrors, PutBotsByBotIdWorkspaceTargetsPrimaryResponses, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdErrors, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdErrors, PutBotsByIdOwnerData, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponses, PutBotsByIdResponses, PutEmailProvidersByIdData, PutEmailProvidersByIdErrors, PutEmailProvidersByIdResponses, PutFetchProvidersByIdData, PutFetchProvidersByIdErrors, PutFetchProvidersByIdResponses, PutMemoryProvidersByIdData, PutMemoryProvidersByIdErrors, PutMemoryProvidersByIdResponses, PutModelsByIdData, PutModelsByIdErrors, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdErrors, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponses, PutSpeechModelsByIdData, PutSpeechModelsByIdErrors, PutSpeechModelsByIdResponses, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdErrors, PutTranscriptionModelsByIdResponses, PutUsersByIdData, PutUsersByIdErrors, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponses, PutVideoModelsByIdData, PutVideoModelsByIdErrors, PutVideoModelsByIdResponses } from './types.gen'; +import type { DeleteBotsByBotIdAclRulesByRuleIdData, DeleteBotsByBotIdAclRulesByRuleIdErrors, DeleteBotsByBotIdAclRulesByRuleIdResponses, DeleteBotsByBotIdAcpRuntimesByRuntimeIdData, DeleteBotsByBotIdAcpRuntimesByRuntimeIdErrors, DeleteBotsByBotIdAcpRuntimesByRuntimeIdResponses, DeleteBotsByBotIdAgentsByIdCredentialData, DeleteBotsByBotIdAgentsByIdCredentialErrors, DeleteBotsByBotIdAgentsByIdCredentialResponses, DeleteBotsByBotIdAgentsByIdData, DeleteBotsByBotIdAgentsByIdErrors, DeleteBotsByBotIdAgentsByIdResponses, DeleteBotsByBotIdChannelManagersByChannelIdentityIdData, DeleteBotsByBotIdChannelManagersByChannelIdentityIdErrors, DeleteBotsByBotIdChannelManagersByChannelIdentityIdResponses, DeleteBotsByBotIdCompactionLogsData, DeleteBotsByBotIdCompactionLogsErrors, DeleteBotsByBotIdCompactionLogsResponses, DeleteBotsByBotIdConnectorsByConnectionIdData, DeleteBotsByBotIdConnectorsByConnectionIdErrors, DeleteBotsByBotIdConnectorsByConnectionIdResponses, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdData, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdErrors, DeleteBotsByBotIdContainerBrowserSessionsBySessionIdResponses, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdData, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdErrors, DeleteBotsByBotIdContainerDisplaySessionsBySessionIdResponses, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdEmailBindingsByIdData, DeleteBotsByBotIdEmailBindingsByIdErrors, DeleteBotsByBotIdEmailBindingsByIdResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdOauthTokenData, DeleteBotsByBotIdMcpByIdOauthTokenErrors, DeleteBotsByBotIdMcpByIdOauthTokenResponses, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdMessagesData, DeleteBotsByBotIdMessagesErrors, DeleteBotsByBotIdMessagesResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdScheduleLogsData, DeleteBotsByBotIdScheduleLogsErrors, DeleteBotsByBotIdScheduleLogsResponses, DeleteBotsByBotIdSessionsBySessionIdData, DeleteBotsByBotIdSessionsBySessionIdErrors, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors, DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses, DeleteBotsByBotIdSessionsBySessionIdResponses, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors, DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSupermarketPackagesByInstallationIdData, DeleteBotsByBotIdSupermarketPackagesByInstallationIdErrors, DeleteBotsByBotIdSupermarketPackagesByInstallationIdResponses, DeleteBotsByBotIdUserAccessByGrantIdData, DeleteBotsByBotIdUserAccessByGrantIdErrors, DeleteBotsByBotIdUserAccessByGrantIdResponses, DeleteBotsByBotIdWorkdirsByWorkdirIdData, DeleteBotsByBotIdWorkdirsByWorkdirIdErrors, DeleteBotsByBotIdWorkdirsByWorkdirIdResponses, DeleteBotsByBotIdWorkspaceTargetsByTargetIdData, DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors, DeleteBotsByBotIdWorkspaceTargetsByTargetIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdErrors, DeleteBotsByIdResponses, DeleteEmailProvidersByIdData, DeleteEmailProvidersByIdErrors, DeleteEmailProvidersByIdOauthTokenData, DeleteEmailProvidersByIdOauthTokenErrors, DeleteEmailProvidersByIdOauthTokenResponses, DeleteEmailProvidersByIdResponses, DeleteFetchProvidersByIdData, DeleteFetchProvidersByIdErrors, DeleteFetchProvidersByIdResponses, DeleteMemoryProvidersByIdData, DeleteMemoryProvidersByIdErrors, DeleteMemoryProvidersByIdResponses, DeleteModelsByIdData, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdErrors, DeleteProvidersByIdOauthTokenData, DeleteProvidersByIdOauthTokenErrors, DeleteProvidersByIdOauthTokenResponses, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, DeleteUsersByIdData, DeleteUsersByIdErrors, DeleteUsersByIdResponses, DeleteUsersMeChannelIdentitiesByChannelIdentityIdData, DeleteUsersMeChannelIdentitiesByChannelIdentityIdErrors, DeleteUsersMeChannelIdentitiesByChannelIdentityIdResponses, DeleteUsersMeRuntimesByIdData, DeleteUsersMeRuntimesByIdErrors, DeleteUsersMeRuntimesByIdResponses, GetAcpProfilesData, GetAcpProfilesResponses, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors, GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses, GetBotsByBotIdAclChannelIdentitiesData, GetBotsByBotIdAclChannelIdentitiesErrors, GetBotsByBotIdAclChannelIdentitiesResponses, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors, GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses, GetBotsByBotIdAclDefaultEffectData, GetBotsByBotIdAclDefaultEffectErrors, GetBotsByBotIdAclDefaultEffectResponses, GetBotsByBotIdAclRulesData, GetBotsByBotIdAclRulesErrors, GetBotsByBotIdAclRulesResponses, GetBotsByBotIdAcpRuntimesByRuntimeIdData, GetBotsByBotIdAcpRuntimesByRuntimeIdErrors, GetBotsByBotIdAcpRuntimesByRuntimeIdResponses, GetBotsByBotIdAgentsByIdCredentialData, GetBotsByBotIdAgentsByIdCredentialErrors, GetBotsByBotIdAgentsByIdCredentialResponses, GetBotsByBotIdAgentsByIdData, GetBotsByBotIdAgentsByIdErrors, GetBotsByBotIdAgentsByIdModelsData, GetBotsByBotIdAgentsByIdModelsErrors, GetBotsByBotIdAgentsByIdModelsResponses, GetBotsByBotIdAgentsByIdResponses, GetBotsByBotIdAgentsData, GetBotsByBotIdAgentsErrors, GetBotsByBotIdAgentsResponses, GetBotsByBotIdBackupSummaryData, GetBotsByBotIdBackupSummaryErrors, GetBotsByBotIdBackupSummaryResponses, GetBotsByBotIdChannelManagersData, GetBotsByBotIdChannelManagersErrors, GetBotsByBotIdChannelManagersResponses, GetBotsByBotIdCompactionLogsData, GetBotsByBotIdCompactionLogsErrors, GetBotsByBotIdCompactionLogsResponses, GetBotsByBotIdConnectorsByConnectionIdData, GetBotsByBotIdConnectorsByConnectionIdErrors, GetBotsByBotIdConnectorsByConnectionIdResponses, GetBotsByBotIdConnectorsData, GetBotsByBotIdConnectorsErrors, GetBotsByBotIdConnectorsResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerDisplayData, GetBotsByBotIdContainerDisplayErrors, GetBotsByBotIdContainerDisplayResponses, GetBotsByBotIdContainerDisplaySessionsData, GetBotsByBotIdContainerDisplaySessionsErrors, GetBotsByBotIdContainerDisplaySessionsResponses, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerFsData, GetBotsByBotIdContainerFsDownloadData, GetBotsByBotIdContainerFsDownloadErrors, GetBotsByBotIdContainerFsDownloadResponses, GetBotsByBotIdContainerFsErrors, GetBotsByBotIdContainerFsListData, GetBotsByBotIdContainerFsListErrors, GetBotsByBotIdContainerFsListResponses, GetBotsByBotIdContainerFsReadData, GetBotsByBotIdContainerFsReadErrors, GetBotsByBotIdContainerFsReadResponses, GetBotsByBotIdContainerFsResponses, GetBotsByBotIdContainerMetricsData, GetBotsByBotIdContainerMetricsErrors, GetBotsByBotIdContainerMetricsResponses, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsErrors, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdContainerTerminalData, GetBotsByBotIdContainerTerminalErrors, GetBotsByBotIdContainerTerminalResponses, GetBotsByBotIdContainerTerminalWsData, GetBotsByBotIdContainerTerminalWsErrors, GetBotsByBotIdEmailBindingsData, GetBotsByBotIdEmailBindingsErrors, GetBotsByBotIdEmailBindingsResponses, GetBotsByBotIdEmailOutboxByIdData, GetBotsByBotIdEmailOutboxByIdErrors, GetBotsByBotIdEmailOutboxByIdResponses, GetBotsByBotIdEmailOutboxData, GetBotsByBotIdEmailOutboxErrors, GetBotsByBotIdEmailOutboxResponses, GetBotsByBotIdHooksEventsData, GetBotsByBotIdHooksEventsErrors, GetBotsByBotIdHooksEventsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdOauthStatusData, GetBotsByBotIdMcpByIdOauthStatusErrors, GetBotsByBotIdMcpByIdOauthStatusResponses, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpOpsExportData, GetBotsByBotIdMcpOpsExportErrors, GetBotsByBotIdMcpOpsExportResponses, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryGraphData, GetBotsByBotIdMemoryGraphErrors, GetBotsByBotIdMemoryGraphResponses, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryStatusData, GetBotsByBotIdMemoryStatusErrors, GetBotsByBotIdMemoryStatusResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesLocateData, GetBotsByBotIdMessagesLocateErrors, GetBotsByBotIdMessagesLocateResponses, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdLogsData, GetBotsByBotIdScheduleByIdLogsErrors, GetBotsByBotIdScheduleByIdLogsResponses, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleLogsData, GetBotsByBotIdScheduleLogsErrors, GetBotsByBotIdScheduleLogsResponses, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSessionsBySessionIdAcpRuntimeData, GetBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, GetBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, GetBotsByBotIdSessionsBySessionIdContextLifecycleData, GetBotsByBotIdSessionsBySessionIdContextLifecycleErrors, GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses, GetBotsByBotIdSessionsBySessionIdData, GetBotsByBotIdSessionsBySessionIdErrors, GetBotsByBotIdSessionsBySessionIdFollowUpQueueData, GetBotsByBotIdSessionsBySessionIdFollowUpQueueErrors, GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponses, GetBotsByBotIdSessionsBySessionIdQueueData, GetBotsByBotIdSessionsBySessionIdQueueErrors, GetBotsByBotIdSessionsBySessionIdQueueResponses, GetBotsByBotIdSessionsBySessionIdResponses, GetBotsByBotIdSessionsBySessionIdStatusData, GetBotsByBotIdSessionsBySessionIdStatusErrors, GetBotsByBotIdSessionsBySessionIdStatusResponses, GetBotsByBotIdSessionsBySessionIdSteerQueueData, GetBotsByBotIdSessionsBySessionIdSteerQueueErrors, GetBotsByBotIdSessionsBySessionIdSteerQueueResponses, GetBotsByBotIdSessionsData, GetBotsByBotIdSessionsErrors, GetBotsByBotIdSessionsEventsData, GetBotsByBotIdSessionsEventsErrors, GetBotsByBotIdSessionsEventsResponse, GetBotsByBotIdSessionsEventsResponses, GetBotsByBotIdSessionsModelPreferenceSeedData, GetBotsByBotIdSessionsModelPreferenceSeedErrors, GetBotsByBotIdSessionsModelPreferenceSeedResponses, GetBotsByBotIdSessionsResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSkillsCatalogData, GetBotsByBotIdSkillsCatalogErrors, GetBotsByBotIdSkillsCatalogResponses, GetBotsByBotIdSupermarketPackagesData, GetBotsByBotIdSupermarketPackagesErrors, GetBotsByBotIdSupermarketPackagesResponses, GetBotsByBotIdTokenUsageData, GetBotsByBotIdTokenUsageErrors, GetBotsByBotIdTokenUsageRecordsData, GetBotsByBotIdTokenUsageRecordsErrors, GetBotsByBotIdTokenUsageRecordsResponses, GetBotsByBotIdTokenUsageResponses, GetBotsByBotIdUserAccessCandidatesData, GetBotsByBotIdUserAccessCandidatesErrors, GetBotsByBotIdUserAccessCandidatesResponses, GetBotsByBotIdUserAccessData, GetBotsByBotIdUserAccessErrors, GetBotsByBotIdUserAccessResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponse, GetBotsByBotIdWebStreamResponses, GetBotsByBotIdWebWsData, GetBotsByBotIdWebWsErrors, GetBotsByBotIdWorkdirsData, GetBotsByBotIdWorkdirsErrors, GetBotsByBotIdWorkdirsResponses, GetBotsByBotIdWorkspaceTargetsData, GetBotsByBotIdWorkspaceTargetsErrors, GetBotsByBotIdWorkspaceTargetsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksErrors, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdErrors, GetBotsByIdResponses, GetBotsData, GetBotsErrors, GetBotsNameAvailabilityData, GetBotsNameAvailabilityErrors, GetBotsNameAvailabilityResponses, GetBotsResponses, GetBotsUserAccessCandidatesData, GetBotsUserAccessCandidatesErrors, GetBotsUserAccessCandidatesResponses, GetChannelsByPlatformData, GetChannelsByPlatformErrors, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsErrors, GetChannelsResponses, GetConnectorsCatalogData, GetConnectorsCatalogErrors, GetConnectorsCatalogResponses, GetEmailOauthCallbackData, GetEmailOauthCallbackErrors, GetEmailOauthCallbackResponses, GetEmailProvidersByIdData, GetEmailProvidersByIdErrors, GetEmailProvidersByIdOauthAuthorizeData, GetEmailProvidersByIdOauthAuthorizeErrors, GetEmailProvidersByIdOauthAuthorizeResponses, GetEmailProvidersByIdOauthStatusData, GetEmailProvidersByIdOauthStatusErrors, GetEmailProvidersByIdOauthStatusResponses, GetEmailProvidersByIdResponses, GetEmailProvidersData, GetEmailProvidersErrors, GetEmailProvidersMetaData, GetEmailProvidersMetaResponses, GetEmailProvidersResponses, GetFetchProvidersByIdData, GetFetchProvidersByIdErrors, GetFetchProvidersByIdResponses, GetFetchProvidersData, GetFetchProvidersErrors, GetFetchProvidersMetaData, GetFetchProvidersMetaResponses, GetFetchProvidersResponses, GetMemoryProvidersByIdData, GetMemoryProvidersByIdErrors, GetMemoryProvidersByIdResponses, GetMemoryProvidersByIdStatusData, GetMemoryProvidersByIdStatusErrors, GetMemoryProvidersByIdStatusResponses, GetMemoryProvidersData, GetMemoryProvidersErrors, GetMemoryProvidersMetaData, GetMemoryProvidersMetaResponses, GetMemoryProvidersResponses, GetModelsByIdData, GetModelsByIdErrors, GetModelsByIdResponses, GetModelsCountData, GetModelsCountErrors, GetModelsCountResponses, GetModelsData, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponses, GetModelsResponses, GetOauthMcpCallbackData, GetOauthMcpCallbackErrors, GetOauthMcpCallbackResponses, GetPingData, GetPingResponses, GetProvidersByIdData, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponses, GetProvidersByIdOauthAuthorizeData, GetProvidersByIdOauthAuthorizeErrors, GetProvidersByIdOauthAuthorizeResponses, GetProvidersByIdOauthStatusData, GetProvidersByIdOauthStatusErrors, GetProvidersByIdOauthStatusResponses, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountErrors, GetProvidersCountResponses, GetProvidersData, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameErrors, GetProvidersNameByNameResponses, GetProvidersOauthCallbackData, GetProvidersOauthCallbackErrors, GetProvidersOauthCallbackResponses, GetProvidersResponses, GetProviderTemplatesByIdData, GetProviderTemplatesByIdErrors, GetProviderTemplatesByIdResponses, GetProviderTemplatesData, GetProviderTemplatesErrors, GetProviderTemplatesResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponses, GetSearchProvidersResponses, GetSpeechModelsByIdCapabilitiesData, GetSpeechModelsByIdCapabilitiesErrors, GetSpeechModelsByIdCapabilitiesResponses, GetSpeechModelsByIdData, GetSpeechModelsByIdErrors, GetSpeechModelsByIdResponses, GetSpeechModelsData, GetSpeechModelsErrors, GetSpeechModelsResponses, GetSpeechProvidersByIdData, GetSpeechProvidersByIdErrors, GetSpeechProvidersByIdModelsData, GetSpeechProvidersByIdModelsErrors, GetSpeechProvidersByIdModelsResponses, GetSpeechProvidersByIdResponses, GetSpeechProvidersData, GetSpeechProvidersErrors, GetSpeechProvidersMetaData, GetSpeechProvidersMetaResponses, GetSpeechProvidersResponses, GetSupermarketArtifactsIconByDigestData, GetSupermarketArtifactsIconByDigestErrors, GetSupermarketArtifactsIconByDigestResponses, GetSupermarketPackagesData, GetSupermarketPackagesErrors, GetSupermarketPackagesResponses, GetSupermarketRegistriesByRegistryIdCategoriesData, GetSupermarketRegistriesByRegistryIdCategoriesErrors, GetSupermarketRegistriesByRegistryIdCategoriesResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdReleasesByRevisionResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdResponses, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdData, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdErrors, GetSupermarketRegistriesByRegistryIdPackagesByPackageIdSkillsBySkillIdResponses, GetSupermarketRegistriesByRegistryIdPackagesData, GetSupermarketRegistriesByRegistryIdPackagesErrors, GetSupermarketRegistriesByRegistryIdPackagesResponses, GetSupermarketRegistriesData, GetSupermarketRegistriesErrors, GetSupermarketRegistriesResponses, GetSupermarketSkillsData, GetSupermarketSkillsErrors, GetSupermarketSkillsResponses, GetTranscriptionModelsByIdCapabilitiesData, GetTranscriptionModelsByIdCapabilitiesErrors, GetTranscriptionModelsByIdCapabilitiesResponses, GetTranscriptionModelsByIdData, GetTranscriptionModelsByIdErrors, GetTranscriptionModelsByIdResponses, GetTranscriptionModelsData, GetTranscriptionModelsErrors, GetTranscriptionModelsResponses, GetTranscriptionProvidersByIdData, GetTranscriptionProvidersByIdErrors, GetTranscriptionProvidersByIdModelsData, GetTranscriptionProvidersByIdModelsErrors, GetTranscriptionProvidersByIdModelsResponses, GetTranscriptionProvidersByIdResponses, GetTranscriptionProvidersData, GetTranscriptionProvidersErrors, GetTranscriptionProvidersMetaData, GetTranscriptionProvidersMetaResponses, GetTranscriptionProvidersResponses, GetUsersByIdData, GetUsersByIdErrors, GetUsersByIdResponses, GetUsersData, GetUsersErrors, GetUsersMeChannelIdentitiesData, GetUsersMeChannelIdentitiesErrors, GetUsersMeChannelIdentitiesResponses, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponses, GetUsersMeComputerAccessData, GetUsersMeComputerAccessErrors, GetUsersMeComputerAccessResponses, GetUsersMeData, GetUsersMeErrors, GetUsersMeResponses, GetUsersMeRuntimesData, GetUsersMeRuntimesErrors, GetUsersMeRuntimesResponses, GetUsersResponses, GetVideoModelsByIdData, GetVideoModelsByIdErrors, GetVideoModelsByIdResponses, GetVideoModelsData, GetVideoModelsErrors, GetVideoModelsResponses, GetVideoProvidersByIdData, GetVideoProvidersByIdErrors, GetVideoProvidersByIdModelsData, GetVideoProvidersByIdModelsErrors, GetVideoProvidersByIdModelsResponses, GetVideoProvidersByIdResponses, GetVideoProvidersData, GetVideoProvidersErrors, GetVideoProvidersMetaData, GetVideoProvidersMetaResponses, GetVideoProvidersResponses, GetWebhookTunnelStatusData, GetWebhookTunnelStatusResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelData, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdModelResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdModeResponses, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningData, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningErrors, PatchBotsByBotIdAcpRuntimesByRuntimeIdReasoningResponses, PatchBotsByBotIdAgentsByIdData, PatchBotsByBotIdAgentsByIdErrors, PatchBotsByBotIdAgentsByIdResponses, PatchBotsByBotIdConnectorsByConnectionIdData, PatchBotsByBotIdConnectorsByConnectionIdErrors, PatchBotsByBotIdConnectorsByConnectionIdResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModelResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeModeResponses, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningData, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningErrors, PatchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoningResponses, PatchBotsByBotIdSessionsBySessionIdData, PatchBotsByBotIdSessionsBySessionIdErrors, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors, PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses, PatchBotsByBotIdSessionsBySessionIdResponses, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors, PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses, PatchBotsByBotIdWorkdirsByWorkdirIdData, PatchBotsByBotIdWorkdirsByWorkdirIdErrors, PatchBotsByBotIdWorkdirsByWorkdirIdResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostBotsBackupImportData, PostBotsBackupImportErrors, PostBotsBackupImportPreviewData, PostBotsBackupImportPreviewErrors, PostBotsBackupImportPreviewResponses, PostBotsBackupImportResponses, PostBotsByBotIdAclRulesData, PostBotsByBotIdAclRulesErrors, PostBotsByBotIdAclRulesResponses, PostBotsByBotIdAcpRuntimesData, PostBotsByBotIdAcpRuntimesErrors, PostBotsByBotIdAcpRuntimesResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeData, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceAuthorizeResponses, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelData, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelErrors, PostBotsByBotIdAgentsByIdCodexLoginDeviceCancelResponses, PostBotsByBotIdAgentsByIdCodexLoginDevicePollData, PostBotsByBotIdAgentsByIdCodexLoginDevicePollErrors, PostBotsByBotIdAgentsByIdCodexLoginDevicePollResponses, PostBotsByBotIdAgentsData, PostBotsByBotIdAgentsErrors, PostBotsByBotIdAgentsResponses, PostBotsByBotIdBackupExportData, PostBotsByBotIdBackupExportErrors, PostBotsByBotIdBackupExportResponses, PostBotsByBotIdChannelManagersData, PostBotsByBotIdChannelManagersErrors, PostBotsByBotIdChannelManagersResponses, PostBotsByBotIdConnectorsApiKeyData, PostBotsByBotIdConnectorsApiKeyErrors, PostBotsByBotIdConnectorsApiKeyResponses, PostBotsByBotIdConnectorsByConnectionIdReauthData, PostBotsByBotIdConnectorsByConnectionIdReauthErrors, PostBotsByBotIdConnectorsByConnectionIdReauthResponses, PostBotsByBotIdConnectorsOauthData, PostBotsByBotIdConnectorsOauthErrors, PostBotsByBotIdConnectorsOauthResponses, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveData, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveErrors, PostBotsByBotIdContainerBrowserSessionsBySessionIdKeepaliveResponses, PostBotsByBotIdContainerBrowserSessionsData, PostBotsByBotIdContainerBrowserSessionsErrors, PostBotsByBotIdContainerBrowserSessionsResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerDataRestoreData, PostBotsByBotIdContainerDataRestoreErrors, PostBotsByBotIdContainerDataRestoreResponses, PostBotsByBotIdContainerDisplayPrepareData, PostBotsByBotIdContainerDisplayPrepareErrors, PostBotsByBotIdContainerDisplayPrepareResponse, PostBotsByBotIdContainerDisplayPrepareResponses, PostBotsByBotIdContainerDisplayWebrtcOfferData, PostBotsByBotIdContainerDisplayWebrtcOfferErrors, PostBotsByBotIdContainerDisplayWebrtcOfferResponses, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerFsArchiveData, PostBotsByBotIdContainerFsArchiveErrors, PostBotsByBotIdContainerFsArchiveResponses, PostBotsByBotIdContainerFsDeleteData, PostBotsByBotIdContainerFsDeleteErrors, PostBotsByBotIdContainerFsDeleteResponses, PostBotsByBotIdContainerFsExtractData, PostBotsByBotIdContainerFsExtractErrors, PostBotsByBotIdContainerFsExtractResponses, PostBotsByBotIdContainerFsMkdirData, PostBotsByBotIdContainerFsMkdirErrors, PostBotsByBotIdContainerFsMkdirResponses, PostBotsByBotIdContainerFsRenameData, PostBotsByBotIdContainerFsRenameErrors, PostBotsByBotIdContainerFsRenameResponses, PostBotsByBotIdContainerFsUploadData, PostBotsByBotIdContainerFsUploadErrors, PostBotsByBotIdContainerFsUploadResponses, PostBotsByBotIdContainerFsWriteData, PostBotsByBotIdContainerFsWriteErrors, PostBotsByBotIdContainerFsWriteResponses, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsActionsData, PostBotsByBotIdContainerSkillsActionsErrors, PostBotsByBotIdContainerSkillsActionsResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerSnapshotsRollbackData, PostBotsByBotIdContainerSnapshotsRollbackErrors, PostBotsByBotIdContainerSnapshotsRollbackResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdEmailBindingsData, PostBotsByBotIdEmailBindingsErrors, PostBotsByBotIdEmailBindingsResponses, PostBotsByBotIdHooksTestData, PostBotsByBotIdHooksTestErrors, PostBotsByBotIdHooksTestResponses, PostBotsByBotIdMcpByIdOauthAuthorizeData, PostBotsByBotIdMcpByIdOauthAuthorizeErrors, PostBotsByBotIdMcpByIdOauthAuthorizeResponses, PostBotsByBotIdMcpByIdOauthDiscoverData, PostBotsByBotIdMcpByIdOauthDiscoverErrors, PostBotsByBotIdMcpByIdOauthDiscoverResponses, PostBotsByBotIdMcpByIdOauthExchangeData, PostBotsByBotIdMcpByIdOauthExchangeErrors, PostBotsByBotIdMcpByIdOauthExchangeResponses, PostBotsByBotIdMcpByIdProbeData, PostBotsByBotIdMcpByIdProbeErrors, PostBotsByBotIdMcpByIdProbeResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryIngestData, PostBotsByBotIdMemoryIngestErrors, PostBotsByBotIdMemoryIngestResponses, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdQuickActionsExecuteData, PostBotsByBotIdQuickActionsExecuteErrors, PostBotsByBotIdQuickActionsExecuteResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSessionsBySessionIdAcpRuntimeData, PostBotsByBotIdSessionsBySessionIdAcpRuntimeErrors, PostBotsByBotIdSessionsBySessionIdAcpRuntimeResponses, PostBotsByBotIdSessionsBySessionIdCompactData, PostBotsByBotIdSessionsBySessionIdCompactErrors, PostBotsByBotIdSessionsBySessionIdCompactResponses, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerErrors, PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponses, PostBotsByBotIdSessionsBySessionIdFollowUpQueueData, PostBotsByBotIdSessionsBySessionIdFollowUpQueueErrors, PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponses, PostBotsByBotIdSessionsBySessionIdForkData, PostBotsByBotIdSessionsBySessionIdForkErrors, PostBotsByBotIdSessionsBySessionIdForkResponses, PostBotsByBotIdSessionsBySessionIdSteerQueueData, PostBotsByBotIdSessionsBySessionIdSteerQueueErrors, PostBotsByBotIdSessionsBySessionIdSteerQueueResponses, PostBotsByBotIdSessionsData, PostBotsByBotIdSessionsErrors, PostBotsByBotIdSessionsResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSupermarketInstallPackageData, PostBotsByBotIdSupermarketInstallPackageErrors, PostBotsByBotIdSupermarketInstallPackageResponses, PostBotsByBotIdToolApprovalsByApprovalIdApproveData, PostBotsByBotIdToolApprovalsByApprovalIdApproveErrors, PostBotsByBotIdToolApprovalsByApprovalIdApproveResponses, PostBotsByBotIdToolApprovalsByApprovalIdRejectData, PostBotsByBotIdToolApprovalsByApprovalIdRejectErrors, PostBotsByBotIdToolApprovalsByApprovalIdRejectResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponses, PostBotsByBotIdTtsSynthesizeData, PostBotsByBotIdTtsSynthesizeErrors, PostBotsByBotIdTtsSynthesizeResponses, PostBotsByBotIdUserAccessData, PostBotsByBotIdUserAccessErrors, PostBotsByBotIdUserAccessResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponses, PostBotsByBotIdWorkdirsData, PostBotsByBotIdWorkdirsErrors, PostBotsByBotIdWorkdirsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponses, PostBotsByIdChannelByPlatformWebhookEndpointData, PostBotsByIdChannelByPlatformWebhookEndpointErrors, PostBotsByIdChannelByPlatformWebhookEndpointResponses, PostBotsData, PostBotsErrors, PostBotsResponses, PostEmailMailgunWebhookByConfigIdData, PostEmailMailgunWebhookByConfigIdErrors, PostEmailMailgunWebhookByConfigIdResponses, PostEmailProvidersData, PostEmailProvidersErrors, PostEmailProvidersResponses, PostFetchProvidersData, PostFetchProvidersErrors, PostFetchProvidersResponses, PostMemoryProvidersData, PostMemoryProvidersErrors, PostMemoryProvidersResponses, PostModelsByIdTestData, PostModelsByIdTestErrors, PostModelsByIdTestResponses, PostModelsData, PostModelsErrors, PostModelsResponses, PostProvidersByIdImportModelsData, PostProvidersByIdImportModelsErrors, PostProvidersByIdImportModelsResponses, PostProvidersByIdOauthPollData, PostProvidersByIdOauthPollErrors, PostProvidersByIdOauthPollResponses, PostProvidersByIdTestData, PostProvidersByIdTestErrors, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersErrors, PostProvidersFromTemplateData, PostProvidersFromTemplateErrors, PostProvidersFromTemplateResponses, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersErrors, PostSearchProvidersResponses, PostSpeechModelsByIdTestData, PostSpeechModelsByIdTestErrors, PostSpeechModelsByIdTestResponses, PostSpeechProvidersByIdImportModelsData, PostSpeechProvidersByIdImportModelsErrors, PostSpeechProvidersByIdImportModelsResponses, PostTranscriptionModelsByIdTestData, PostTranscriptionModelsByIdTestErrors, PostTranscriptionModelsByIdTestResponses, PostTranscriptionProvidersByIdImportModelsData, PostTranscriptionProvidersByIdImportModelsErrors, PostTranscriptionProvidersByIdImportModelsResponses, PostUsersData, PostUsersErrors, PostUsersMeChannelLinksData, PostUsersMeChannelLinksErrors, PostUsersMeChannelLinksResponses, PostUsersMeRuntimesData, PostUsersMeRuntimesErrors, PostUsersMeRuntimesResponses, PostUsersResponses, PostVideoProvidersByIdImportModelsData, PostVideoProvidersByIdImportModelsErrors, PostVideoProvidersByIdImportModelsResponses, PutBotsByBotIdAclDefaultEffectData, PutBotsByBotIdAclDefaultEffectErrors, PutBotsByBotIdAclDefaultEffectResponses, PutBotsByBotIdAclRulesByRuleIdData, PutBotsByBotIdAclRulesByRuleIdErrors, PutBotsByBotIdAclRulesByRuleIdResponses, PutBotsByBotIdAgentsByIdCredentialData, PutBotsByBotIdAgentsByIdCredentialErrors, PutBotsByBotIdAgentsByIdCredentialResponses, PutBotsByBotIdContainerMetricsData, PutBotsByBotIdContainerMetricsErrors, PutBotsByBotIdContainerMetricsResponses, PutBotsByBotIdEmailBindingsByIdData, PutBotsByBotIdEmailBindingsByIdErrors, PutBotsByBotIdEmailBindingsByIdResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpOpsImportData, PutBotsByBotIdMcpOpsImportErrors, PutBotsByBotIdMcpOpsImportResponses, PutBotsByBotIdMemoryByMemoryIdData, PutBotsByBotIdMemoryByMemoryIdErrors, PutBotsByBotIdMemoryByMemoryIdResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderData, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderErrors, PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponses, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderData, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderErrors, PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponses, PutBotsByBotIdUserAccessByGrantIdData, PutBotsByBotIdUserAccessByGrantIdErrors, PutBotsByBotIdUserAccessByGrantIdResponses, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalData, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalErrors, PutBotsByBotIdWorkspaceTargetsByTargetIdToolApprovalResponses, PutBotsByBotIdWorkspaceTargetsPrimaryData, PutBotsByBotIdWorkspaceTargetsPrimaryErrors, PutBotsByBotIdWorkspaceTargetsPrimaryResponses, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdData, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdErrors, PutBotsByBotIdWorkspaceTargetsRemotesByRuntimeIdResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdErrors, PutBotsByIdOwnerData, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponses, PutBotsByIdResponses, PutEmailProvidersByIdData, PutEmailProvidersByIdErrors, PutEmailProvidersByIdResponses, PutFetchProvidersByIdData, PutFetchProvidersByIdErrors, PutFetchProvidersByIdResponses, PutMemoryProvidersByIdData, PutMemoryProvidersByIdErrors, PutMemoryProvidersByIdResponses, PutModelsByIdData, PutModelsByIdErrors, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdErrors, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponses, PutSpeechModelsByIdData, PutSpeechModelsByIdErrors, PutSpeechModelsByIdResponses, PutTranscriptionModelsByIdData, PutTranscriptionModelsByIdErrors, PutTranscriptionModelsByIdResponses, PutUsersByIdData, PutUsersByIdErrors, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponses, PutVideoModelsByIdData, PutVideoModelsByIdErrors, PutVideoModelsByIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -1400,6 +1400,57 @@ export const postBotsByBotIdSessionsBySessionIdCompact = (options: Options): RequestResult => (options.client ?? client).get({ url: '/bots/{bot_id}/sessions/{session_id}/context-lifecycle', ...options }); +/** + * List pending follow-up inputs + */ +export const getBotsByBotIdSessionsBySessionIdFollowUpQueue = (options: Options): RequestResult => (options.client ?? client).get({ url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue', ...options }); + +/** + * Enqueue follow-up input for the active session run + */ +export const postBotsByBotIdSessionsBySessionIdFollowUpQueue = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Reorder accepted follow-up inputs + */ +export const putBotsByBotIdSessionsBySessionIdFollowUpQueueReorder = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Cancel an accepted follow-up input + */ +export const deleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}', ...options }); + +/** + * Edit an accepted follow-up input + */ +export const patchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemId = (options: Options): RequestResult => (options.client ?? client).patch({ + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Promote an accepted follow-up input to steer the active run + */ +export const postBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteer = (options: Options): RequestResult => (options.client ?? client).post({ url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer', ...options }); + /** * Fork a chat session from an assistant reply */ @@ -1412,6 +1463,11 @@ export const postBotsByBotIdSessionsBySessionIdFork = (options: Options): RequestResult => (options.client ?? client).get({ url: '/bots/{bot_id}/sessions/{session_id}/queue', ...options }); + /** * Get session info * @@ -1419,6 +1475,52 @@ export const postBotsByBotIdSessionsBySessionIdFork = (options: Options): RequestResult => (options.client ?? client).get({ url: '/bots/{bot_id}/sessions/{session_id}/status', ...options }); +/** + * List pending steer inputs + */ +export const getBotsByBotIdSessionsBySessionIdSteerQueue = (options: Options): RequestResult => (options.client ?? client).get({ url: '/bots/{bot_id}/sessions/{session_id}/steer-queue', ...options }); + +/** + * Enqueue steer input for the active session run + */ +export const postBotsByBotIdSessionsBySessionIdSteerQueue = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Reorder accepted steer inputs + */ +export const putBotsByBotIdSessionsBySessionIdSteerQueueReorder = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/reorder', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Cancel an accepted steer input + */ +export const deleteBotsByBotIdSessionsBySessionIdSteerQueueByItemId = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}', ...options }); + +/** + * Edit an accepted steer input + */ +export const patchBotsByBotIdSessionsBySessionIdSteerQueueByItemId = (options: Options): RequestResult => (options.client ?? client).patch({ + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Delete user settings * diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index 1e3c3a5dc..5fc20d341 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -2617,6 +2617,28 @@ export type HandlersEmailOAuthStatusResponse = { provider?: string; }; +export type HandlersEnqueueQueueRequest = { + invocation_id: string; + text: string; +}; + +export type HandlersFollowUpQueueItemResponse = { + enqueued_during_run_id?: string; + item_id?: string; + position?: number; + status?: SessionruntimeQueueStatus; + text?: string; +}; + +export type HandlersFollowUpQueueReorderRequest = { + before?: SessionruntimeFollowUpPendingRef; + item?: SessionruntimeFollowUpPendingRef; +}; + +export type HandlersFollowUpQueueResponse = { + items?: Array; +}; + export type HandlersForkSessionRequest = { /** * MessageID is the pre-turn spelling of TurnID, resolved server-side to the @@ -2727,10 +2749,33 @@ export type HandlersOauthExchangeRequest = { state?: string; }; +export type HandlersSessionQueueResponse = { + follow_up?: Array; + steer?: Array; + steer_supported?: boolean; +}; + export type HandlersSkillsOpResponse = { ok?: boolean; }; +export type HandlersSteerQueueItemResponse = { + item_id?: string; + position?: number; + status?: SessionruntimeQueueStatus; + target_run_id?: string; + text?: string; +}; + +export type HandlersSteerQueueReorderRequest = { + before?: SessionruntimeSteerPendingRef; + item?: SessionruntimeSteerPendingRef; +}; + +export type HandlersSteerQueueResponse = { + items?: Array; +}; + export type HandlersSynthesizeRequest = { text?: string; }; @@ -2746,6 +2791,10 @@ export type HandlersTerminalInfoResponse = { shell?: string; }; +export type HandlersUpdateQueueRequest = { + text: string; +}; + export type HandlersUpdateSessionRequest = { bot_agent_id?: string; expected_model_preference_revision?: string; @@ -2756,6 +2805,8 @@ export type HandlersUpdateSessionRequest = { * PreferredChatModelID / PreferredReasoningEffort are the picker pair * (issue #879). The composer always patches the pair together; either one * alone is reconciled against the model the session would actually use. + * Changing either requires ExpectedModelPreferenceRevision ("" when the + * session has none yet): picker writes are always compare-and-set. */ preferred_chat_model_id?: string; preferred_reasoning_effort?: string; @@ -3461,6 +3512,16 @@ export type SessionSession = { workdir_id?: string; }; +export type SessionruntimeFollowUpPendingRef = { + item_id?: string; +}; + +export type SessionruntimeQueueStatus = 'accepted' | 'claimed' | 'applied' | 'rejected' | 'expired' | 'canceled'; + +export type SessionruntimeSteerPendingRef = { + item_id?: string; +}; + export type SettingsSettings = { acl_default_effect?: string; chat_acp_agent_id?: string; @@ -10033,6 +10094,269 @@ export type GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses = { export type GetBotsByBotIdSessionsBySessionIdContextLifecycleResponse = GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses[keyof GetBotsByBotIdSessionsBySessionIdContextLifecycleResponses]; +export type GetBotsByBotIdSessionsBySessionIdFollowUpQueueData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue'; +}; + +export type GetBotsByBotIdSessionsBySessionIdFollowUpQueueErrors = { + /** + * Forbidden + */ + 403: ApperrorProblem; +}; + +export type GetBotsByBotIdSessionsBySessionIdFollowUpQueueError = GetBotsByBotIdSessionsBySessionIdFollowUpQueueErrors[keyof GetBotsByBotIdSessionsBySessionIdFollowUpQueueErrors]; + +export type GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponses = { + /** + * OK + */ + 200: HandlersFollowUpQueueResponse; +}; + +export type GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponse = GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponses[keyof GetBotsByBotIdSessionsBySessionIdFollowUpQueueResponses]; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueData = { + /** + * Follow-up payload + */ + body: HandlersEnqueueQueueRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue'; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueError = PostBotsByBotIdSessionsBySessionIdFollowUpQueueErrors[keyof PostBotsByBotIdSessionsBySessionIdFollowUpQueueErrors]; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponses = { + /** + * Accepted + */ + 202: HandlersFollowUpQueueItemResponse; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponse = PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponses[keyof PostBotsByBotIdSessionsBySessionIdFollowUpQueueResponses]; + +export type PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderData = { + /** + * Typed follow-up queue references + */ + body: HandlersFollowUpQueueReorderRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder'; +}; + +export type PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderError = PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderErrors[keyof PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderErrors]; + +export type PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponses = { + /** + * OK + */ + 200: HandlersFollowUpQueueResponse; +}; + +export type PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponse = PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponses[keyof PutBotsByBotIdSessionsBySessionIdFollowUpQueueReorderResponses]; + +export type DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + /** + * Queue item ID + */ + item_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}'; +}; + +export type DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError = DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors[keyof DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors]; + +export type DeleteBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses = { + /** + * No Content + */ + 204: unknown; +}; + +export type PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdData = { + /** + * Updated follow-up payload + */ + body: HandlersUpdateQueueRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + /** + * Queue item ID + */ + item_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}'; +}; + +export type PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdError = PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors[keyof PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdErrors]; + +export type PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses = { + /** + * OK + */ + 200: HandlersFollowUpQueueItemResponse; +}; + +export type PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponse = PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses[keyof PatchBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdResponses]; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + /** + * Follow-up queue item ID + */ + item_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer'; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerError = PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerErrors[keyof PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerErrors]; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponses = { + /** + * Accepted + */ + 202: HandlersSteerQueueItemResponse; +}; + +export type PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponse = PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponses[keyof PostBotsByBotIdSessionsBySessionIdFollowUpQueueByItemIdSteerResponses]; + export type PostBotsByBotIdSessionsBySessionIdForkData = { /** * Fork source turn @@ -10082,6 +10406,40 @@ export type PostBotsByBotIdSessionsBySessionIdForkResponses = { export type PostBotsByBotIdSessionsBySessionIdForkResponse = PostBotsByBotIdSessionsBySessionIdForkResponses[keyof PostBotsByBotIdSessionsBySessionIdForkResponses]; +export type GetBotsByBotIdSessionsBySessionIdQueueData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/queue'; +}; + +export type GetBotsByBotIdSessionsBySessionIdQueueErrors = { + /** + * Forbidden + */ + 403: ApperrorProblem; +}; + +export type GetBotsByBotIdSessionsBySessionIdQueueError = GetBotsByBotIdSessionsBySessionIdQueueErrors[keyof GetBotsByBotIdSessionsBySessionIdQueueErrors]; + +export type GetBotsByBotIdSessionsBySessionIdQueueResponses = { + /** + * OK + */ + 200: HandlersSessionQueueResponse; +}; + +export type GetBotsByBotIdSessionsBySessionIdQueueResponse = GetBotsByBotIdSessionsBySessionIdQueueResponses[keyof GetBotsByBotIdSessionsBySessionIdQueueResponses]; + export type GetBotsByBotIdSessionsBySessionIdStatusData = { body?: never; path: { @@ -10129,6 +10487,223 @@ export type GetBotsByBotIdSessionsBySessionIdStatusResponses = { export type GetBotsByBotIdSessionsBySessionIdStatusResponse = GetBotsByBotIdSessionsBySessionIdStatusResponses[keyof GetBotsByBotIdSessionsBySessionIdStatusResponses]; +export type GetBotsByBotIdSessionsBySessionIdSteerQueueData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue'; +}; + +export type GetBotsByBotIdSessionsBySessionIdSteerQueueErrors = { + /** + * Forbidden + */ + 403: ApperrorProblem; +}; + +export type GetBotsByBotIdSessionsBySessionIdSteerQueueError = GetBotsByBotIdSessionsBySessionIdSteerQueueErrors[keyof GetBotsByBotIdSessionsBySessionIdSteerQueueErrors]; + +export type GetBotsByBotIdSessionsBySessionIdSteerQueueResponses = { + /** + * OK + */ + 200: HandlersSteerQueueResponse; +}; + +export type GetBotsByBotIdSessionsBySessionIdSteerQueueResponse = GetBotsByBotIdSessionsBySessionIdSteerQueueResponses[keyof GetBotsByBotIdSessionsBySessionIdSteerQueueResponses]; + +export type PostBotsByBotIdSessionsBySessionIdSteerQueueData = { + /** + * Steer payload + */ + body: HandlersEnqueueQueueRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue'; +}; + +export type PostBotsByBotIdSessionsBySessionIdSteerQueueErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PostBotsByBotIdSessionsBySessionIdSteerQueueError = PostBotsByBotIdSessionsBySessionIdSteerQueueErrors[keyof PostBotsByBotIdSessionsBySessionIdSteerQueueErrors]; + +export type PostBotsByBotIdSessionsBySessionIdSteerQueueResponses = { + /** + * Accepted + */ + 202: HandlersSteerQueueItemResponse; +}; + +export type PostBotsByBotIdSessionsBySessionIdSteerQueueResponse = PostBotsByBotIdSessionsBySessionIdSteerQueueResponses[keyof PostBotsByBotIdSessionsBySessionIdSteerQueueResponses]; + +export type PutBotsByBotIdSessionsBySessionIdSteerQueueReorderData = { + /** + * Typed steer queue references + */ + body: HandlersSteerQueueReorderRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/reorder'; +}; + +export type PutBotsByBotIdSessionsBySessionIdSteerQueueReorderErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PutBotsByBotIdSessionsBySessionIdSteerQueueReorderError = PutBotsByBotIdSessionsBySessionIdSteerQueueReorderErrors[keyof PutBotsByBotIdSessionsBySessionIdSteerQueueReorderErrors]; + +export type PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponses = { + /** + * OK + */ + 200: HandlersSteerQueueResponse; +}; + +export type PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponse = PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponses[keyof PutBotsByBotIdSessionsBySessionIdSteerQueueReorderResponses]; + +export type DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + /** + * Queue item ID + */ + item_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}'; +}; + +export type DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError = DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors[keyof DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors]; + +export type DeleteBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses = { + /** + * No Content + */ + 204: unknown; +}; + +export type PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdData = { + /** + * Updated steer payload + */ + body: HandlersUpdateQueueRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + /** + * Session ID + */ + session_id: string; + /** + * Queue item ID + */ + item_id: string; + }; + query?: never; + url: '/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}'; +}; + +export type PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors = { + /** + * Bad Request + */ + 400: ApperrorProblem; + /** + * Forbidden + */ + 403: ApperrorProblem; + /** + * Conflict + */ + 409: ApperrorProblem; +}; + +export type PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdError = PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors[keyof PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdErrors]; + +export type PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses = { + /** + * OK + */ + 200: HandlersSteerQueueItemResponse; +}; + +export type PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponse = PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses[keyof PatchBotsByBotIdSessionsBySessionIdSteerQueueByItemIdResponses]; + export type DeleteBotsByBotIdSettingsData = { body?: never; path: { diff --git a/spec/docs.go b/spec/docs.go index f2eb62a70..5818da7f4 100644 --- a/spec/docs.go +++ b/spec/docs.go @@ -7786,12 +7786,596 @@ const docTemplate = `{ } } }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending follow-up inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "post": { + "tags": [ + "sessions" + ], + "summary": "Enqueue follow-up input for the active session run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Follow-up payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.enqueueQueueRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder": { + "put": { + "tags": [ + "sessions" + ], + "summary": "Reorder accepted follow-up inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Typed follow-up queue references", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.followUpQueueReorderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}": { + "delete": { + "tags": [ + "sessions" + ], + "summary": "Cancel an accepted follow-up input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "patch": { + "tags": [ + "sessions" + ], + "summary": "Edit an accepted follow-up input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + }, + { + "description": "Updated follow-up payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQueueRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer": { + "post": { + "tags": [ + "sessions" + ], + "summary": "Promote an accepted follow-up input to steer the active run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Follow-up queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, "/bots/{bot_id}/sessions/{session_id}/fork": { "post": { "tags": [ "sessions" ], - "summary": "Fork a chat session from an assistant reply", + "summary": "Fork a chat session from an assistant reply", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Source session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Fork source turn", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.forkSessionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/session.Session" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending steer and follow-up inputs in one response", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.sessionQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/status": { + "get": { + "description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills", + "tags": [ + "sessions" + ], + "summary": "Get session info", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional model UUID override for context window", + "name": "model_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SessionInfoResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending steer inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.steerQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "post": { + "tags": [ + "sessions" + ], + "summary": "Enqueue steer input for the active session run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Steer payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.enqueueQueueRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue/reorder": { + "put": { + "tags": [ + "sessions" + ], + "summary": "Reorder accepted steer inputs", "parameters": [ { "type": "string", @@ -7802,62 +8386,107 @@ const docTemplate = `{ }, { "type": "string", - "description": "Source session ID", + "description": "Session ID", "name": "session_id", "in": "path", "required": true }, { - "description": "Fork source turn", + "description": "Typed steer queue references", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.forkSessionRequest" + "$ref": "#/definitions/handlers.steerQueueReorderRequest" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/session.Session" + "$ref": "#/definitions/handlers.steerQueueResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}": { + "delete": { + "tags": [ + "sessions" + ], + "summary": "Cancel an accepted steer input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } } } - } - }, - "/bots/{bot_id}/sessions/{session_id}/status": { - "get": { - "description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills", + }, + "patch": { "tags": [ "sessions" ], - "summary": "Get session info", + "summary": "Edit an accepted steer input", "parameters": [ { "type": "string", @@ -7875,34 +8504,44 @@ const docTemplate = `{ }, { "type": "string", - "description": "Optional model UUID override for context window", - "name": "model_id", - "in": "query" + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + }, + { + "description": "Updated steer payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQueueRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.SessionInfoResponse" + "$ref": "#/definitions/handlers.steerQueueItemResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, - "500": { - "description": "Internal Server Error", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } } } @@ -21980,6 +22619,63 @@ const docTemplate = `{ } } }, + "handlers.enqueueQueueRequest": { + "type": "object", + "required": [ + "invocation_id", + "text" + ], + "properties": { + "invocation_id": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "handlers.followUpQueueItemResponse": { + "type": "object", + "properties": { + "enqueued_during_run_id": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/sessionruntime.QueueStatus" + }, + "text": { + "type": "string" + } + } + }, + "handlers.followUpQueueReorderRequest": { + "type": "object", + "properties": { + "before": { + "$ref": "#/definitions/sessionruntime.FollowUpPendingRef" + }, + "item": { + "$ref": "#/definitions/sessionruntime.FollowUpPendingRef" + } + } + }, + "handlers.followUpQueueResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + } + } + }, "handlers.forkSessionRequest": { "type": "object", "properties": { @@ -22239,6 +22935,26 @@ const docTemplate = `{ } } }, + "handlers.sessionQueueResponse": { + "type": "object", + "properties": { + "follow_up": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "steer": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "steer_supported": { + "type": "boolean" + } + } + }, "handlers.skillsOpResponse": { "type": "object", "properties": { @@ -22247,6 +22963,48 @@ const docTemplate = `{ } } }, + "handlers.steerQueueItemResponse": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/sessionruntime.QueueStatus" + }, + "target_run_id": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "handlers.steerQueueReorderRequest": { + "type": "object", + "properties": { + "before": { + "$ref": "#/definitions/sessionruntime.SteerPendingRef" + }, + "item": { + "$ref": "#/definitions/sessionruntime.SteerPendingRef" + } + } + }, + "handlers.steerQueueResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + } + } + }, "handlers.synthesizeRequest": { "type": "object", "properties": { @@ -22280,6 +23038,17 @@ const docTemplate = `{ } } }, + "handlers.updateQueueRequest": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string" + } + } + }, "handlers.updateSessionRequest": { "type": "object", "properties": { @@ -22294,7 +23063,7 @@ const docTemplate = `{ "additionalProperties": {} }, "preferred_chat_model_id": { - "description": "PreferredChatModelID / PreferredReasoningEffort are the picker pair\n(issue #879). The composer always patches the pair together; either one\nalone is reconciled against the model the session would actually use.", + "description": "PreferredChatModelID / PreferredReasoningEffort are the picker pair\n(issue #879). The composer always patches the pair together; either one\nalone is reconciled against the model the session would actually use.\nChanging either requires ExpectedModelPreferenceRevision (\"\" when the\nsession has none yet): picker writes are always compare-and-set.", "type": "string" }, "preferred_reasoning_effort": { @@ -23750,6 +24519,41 @@ const docTemplate = `{ } } }, + "sessionruntime.FollowUpPendingRef": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + } + } + }, + "sessionruntime.QueueStatus": { + "type": "string", + "enum": [ + "accepted", + "claimed", + "applied", + "rejected", + "expired", + "canceled" + ], + "x-enum-varnames": [ + "QueueAccepted", + "QueueClaimed", + "QueueApplied", + "QueueRejected", + "QueueExpired", + "QueueCanceled" + ] + }, + "sessionruntime.SteerPendingRef": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + } + } + }, "settings.Settings": { "type": "object", "properties": { diff --git a/spec/swagger.json b/spec/swagger.json index eb1c67a1c..dab23aec1 100644 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -7777,12 +7777,596 @@ } } }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending follow-up inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "post": { + "tags": [ + "sessions" + ], + "summary": "Enqueue follow-up input for the active session run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Follow-up payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.enqueueQueueRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder": { + "put": { + "tags": [ + "sessions" + ], + "summary": "Reorder accepted follow-up inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Typed follow-up queue references", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.followUpQueueReorderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}": { + "delete": { + "tags": [ + "sessions" + ], + "summary": "Cancel an accepted follow-up input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "patch": { + "tags": [ + "sessions" + ], + "summary": "Edit an accepted follow-up input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + }, + { + "description": "Updated follow-up payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQueueRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer": { + "post": { + "tags": [ + "sessions" + ], + "summary": "Promote an accepted follow-up input to steer the active run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Follow-up queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, "/bots/{bot_id}/sessions/{session_id}/fork": { "post": { "tags": [ "sessions" ], - "summary": "Fork a chat session from an assistant reply", + "summary": "Fork a chat session from an assistant reply", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Source session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Fork source turn", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.forkSessionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/session.Session" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending steer and follow-up inputs in one response", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.sessionQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/status": { + "get": { + "description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills", + "tags": [ + "sessions" + ], + "summary": "Get session info", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional model UUID override for context window", + "name": "model_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SessionInfoResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue": { + "get": { + "tags": [ + "sessions" + ], + "summary": "List pending steer inputs", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.steerQueueResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + }, + "post": { + "tags": [ + "sessions" + ], + "summary": "Enqueue steer input for the active session run", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "Steer payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.enqueueQueueRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue/reorder": { + "put": { + "tags": [ + "sessions" + ], + "summary": "Reorder accepted steer inputs", "parameters": [ { "type": "string", @@ -7793,62 +8377,107 @@ }, { "type": "string", - "description": "Source session ID", + "description": "Session ID", "name": "session_id", "in": "path", "required": true }, { - "description": "Fork source turn", + "description": "Typed steer queue references", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.forkSessionRequest" + "$ref": "#/definitions/handlers.steerQueueReorderRequest" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/session.Session" + "$ref": "#/definitions/handlers.steerQueueResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" + } + } + } + } + }, + "/bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}": { + "delete": { + "tags": [ + "sessions" + ], + "summary": "Cancel an accepted steer input", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/apperror.Problem" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } } } - } - }, - "/bots/{bot_id}/sessions/{session_id}/status": { - "get": { - "description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills", + }, + "patch": { "tags": [ "sessions" ], - "summary": "Get session info", + "summary": "Edit an accepted steer input", "parameters": [ { "type": "string", @@ -7866,34 +8495,44 @@ }, { "type": "string", - "description": "Optional model UUID override for context window", - "name": "model_id", - "in": "query" + "description": "Queue item ID", + "name": "item_id", + "in": "path", + "required": true + }, + { + "description": "Updated steer payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQueueRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.SessionInfoResponse" + "$ref": "#/definitions/handlers.steerQueueItemResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } }, - "500": { - "description": "Internal Server Error", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" + "$ref": "#/definitions/apperror.Problem" } } } @@ -21971,6 +22610,63 @@ } } }, + "handlers.enqueueQueueRequest": { + "type": "object", + "required": [ + "invocation_id", + "text" + ], + "properties": { + "invocation_id": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "handlers.followUpQueueItemResponse": { + "type": "object", + "properties": { + "enqueued_during_run_id": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/sessionruntime.QueueStatus" + }, + "text": { + "type": "string" + } + } + }, + "handlers.followUpQueueReorderRequest": { + "type": "object", + "properties": { + "before": { + "$ref": "#/definitions/sessionruntime.FollowUpPendingRef" + }, + "item": { + "$ref": "#/definitions/sessionruntime.FollowUpPendingRef" + } + } + }, + "handlers.followUpQueueResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + } + } + }, "handlers.forkSessionRequest": { "type": "object", "properties": { @@ -22230,6 +22926,26 @@ } } }, + "handlers.sessionQueueResponse": { + "type": "object", + "properties": { + "follow_up": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.followUpQueueItemResponse" + } + }, + "steer": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + }, + "steer_supported": { + "type": "boolean" + } + } + }, "handlers.skillsOpResponse": { "type": "object", "properties": { @@ -22238,6 +22954,48 @@ } } }, + "handlers.steerQueueItemResponse": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/sessionruntime.QueueStatus" + }, + "target_run_id": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "handlers.steerQueueReorderRequest": { + "type": "object", + "properties": { + "before": { + "$ref": "#/definitions/sessionruntime.SteerPendingRef" + }, + "item": { + "$ref": "#/definitions/sessionruntime.SteerPendingRef" + } + } + }, + "handlers.steerQueueResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.steerQueueItemResponse" + } + } + } + }, "handlers.synthesizeRequest": { "type": "object", "properties": { @@ -22271,6 +23029,17 @@ } } }, + "handlers.updateQueueRequest": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string" + } + } + }, "handlers.updateSessionRequest": { "type": "object", "properties": { @@ -22285,7 +23054,7 @@ "additionalProperties": {} }, "preferred_chat_model_id": { - "description": "PreferredChatModelID / PreferredReasoningEffort are the picker pair\n(issue #879). The composer always patches the pair together; either one\nalone is reconciled against the model the session would actually use.", + "description": "PreferredChatModelID / PreferredReasoningEffort are the picker pair\n(issue #879). The composer always patches the pair together; either one\nalone is reconciled against the model the session would actually use.\nChanging either requires ExpectedModelPreferenceRevision (\"\" when the\nsession has none yet): picker writes are always compare-and-set.", "type": "string" }, "preferred_reasoning_effort": { @@ -23741,6 +24510,41 @@ } } }, + "sessionruntime.FollowUpPendingRef": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + } + } + }, + "sessionruntime.QueueStatus": { + "type": "string", + "enum": [ + "accepted", + "claimed", + "applied", + "rejected", + "expired", + "canceled" + ], + "x-enum-varnames": [ + "QueueAccepted", + "QueueClaimed", + "QueueApplied", + "QueueRejected", + "QueueExpired", + "QueueCanceled" + ] + }, + "sessionruntime.SteerPendingRef": { + "type": "object", + "properties": { + "item_id": { + "type": "string" + } + } + }, "settings.Settings": { "type": "object", "properties": { diff --git a/spec/swagger.yaml b/spec/swagger.yaml index b5e732e1a..5fd29a3d8 100644 --- a/spec/swagger.yaml +++ b/spec/swagger.yaml @@ -4636,6 +4636,43 @@ definitions: provider: type: string type: object + handlers.enqueueQueueRequest: + properties: + invocation_id: + type: string + text: + type: string + required: + - invocation_id + - text + type: object + handlers.followUpQueueItemResponse: + properties: + enqueued_during_run_id: + type: string + item_id: + type: string + position: + type: integer + status: + $ref: '#/definitions/sessionruntime.QueueStatus' + text: + type: string + type: object + handlers.followUpQueueReorderRequest: + properties: + before: + $ref: '#/definitions/sessionruntime.FollowUpPendingRef' + item: + $ref: '#/definitions/sessionruntime.FollowUpPendingRef' + type: object + handlers.followUpQueueResponse: + properties: + items: + items: + $ref: '#/definitions/handlers.followUpQueueItemResponse' + type: array + type: object handlers.forkSessionRequest: properties: message_id: @@ -4810,11 +4847,51 @@ definitions: state: type: string type: object + handlers.sessionQueueResponse: + properties: + follow_up: + items: + $ref: '#/definitions/handlers.followUpQueueItemResponse' + type: array + steer: + items: + $ref: '#/definitions/handlers.steerQueueItemResponse' + type: array + steer_supported: + type: boolean + type: object handlers.skillsOpResponse: properties: ok: type: boolean type: object + handlers.steerQueueItemResponse: + properties: + item_id: + type: string + position: + type: integer + status: + $ref: '#/definitions/sessionruntime.QueueStatus' + target_run_id: + type: string + text: + type: string + type: object + handlers.steerQueueReorderRequest: + properties: + before: + $ref: '#/definitions/sessionruntime.SteerPendingRef' + item: + $ref: '#/definitions/sessionruntime.SteerPendingRef' + type: object + handlers.steerQueueResponse: + properties: + items: + items: + $ref: '#/definitions/handlers.steerQueueItemResponse' + type: array + type: object handlers.synthesizeRequest: properties: text: @@ -4836,6 +4913,13 @@ definitions: shell: type: string type: object + handlers.updateQueueRequest: + properties: + text: + type: string + required: + - text + type: object handlers.updateSessionRequest: properties: bot_agent_id: @@ -4850,6 +4934,8 @@ definitions: PreferredChatModelID / PreferredReasoningEffort are the picker pair (issue #879). The composer always patches the pair together; either one alone is reconciled against the model the session would actually use. + Changing either requires ExpectedModelPreferenceRevision ("" when the + session has none yet): picker writes are always compare-and-set. type: string preferred_reasoning_effort: type: string @@ -5906,6 +5992,32 @@ definitions: workdir_id: type: string type: object + sessionruntime.FollowUpPendingRef: + properties: + item_id: + type: string + type: object + sessionruntime.QueueStatus: + enum: + - accepted + - claimed + - applied + - rejected + - expired + - canceled + type: string + x-enum-varnames: + - QueueAccepted + - QueueClaimed + - QueueApplied + - QueueRejected + - QueueExpired + - QueueCanceled + sessionruntime.SteerPendingRef: + properties: + item_id: + type: string + type: object settings.Settings: properties: acl_default_effect: @@ -11484,6 +11596,225 @@ paths: summary: Get session context lifecycle tags: - sessions + /bots/{bot_id}/sessions/{session_id}/follow-up-queue: + get: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.followUpQueueResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + summary: List pending follow-up inputs + tags: + - sessions + post: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Follow-up payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.enqueueQueueRequest' + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/handlers.followUpQueueItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Enqueue follow-up input for the active session run + tags: + - sessions + /bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}: + delete: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Queue item ID + in: path + name: item_id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Cancel an accepted follow-up input + tags: + - sessions + patch: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Queue item ID + in: path + name: item_id + required: true + type: string + - description: Updated follow-up payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQueueRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.followUpQueueItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Edit an accepted follow-up input + tags: + - sessions + /bots/{bot_id}/sessions/{session_id}/follow-up-queue/{item_id}/steer: + post: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Follow-up queue item ID + in: path + name: item_id + required: true + type: string + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/handlers.steerQueueItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Promote an accepted follow-up input to steer the active run + tags: + - sessions + /bots/{bot_id}/sessions/{session_id}/follow-up-queue/reorder: + put: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Typed follow-up queue references + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.followUpQueueReorderRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.followUpQueueResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Reorder accepted follow-up inputs + tags: + - sessions /bots/{bot_id}/sessions/{session_id}/fork: post: parameters: @@ -11527,6 +11858,31 @@ paths: summary: Fork a chat session from an assistant reply tags: - sessions + /bots/{bot_id}/sessions/{session_id}/queue: + get: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.sessionQueueResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + summary: List pending steer and follow-up inputs in one response + tags: + - sessions /bots/{bot_id}/sessions/{session_id}/status: get: description: Get aggregated info for a chat session including message count, @@ -11566,6 +11922,187 @@ paths: summary: Get session info tags: - sessions + /bots/{bot_id}/sessions/{session_id}/steer-queue: + get: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.steerQueueResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + summary: List pending steer inputs + tags: + - sessions + post: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Steer payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.enqueueQueueRequest' + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/handlers.steerQueueItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Enqueue steer input for the active session run + tags: + - sessions + /bots/{bot_id}/sessions/{session_id}/steer-queue/{item_id}: + delete: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Queue item ID + in: path + name: item_id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Cancel an accepted steer input + tags: + - sessions + patch: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Queue item ID + in: path + name: item_id + required: true + type: string + - description: Updated steer payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQueueRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.steerQueueItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Edit an accepted steer input + tags: + - sessions + /bots/{bot_id}/sessions/{session_id}/steer-queue/reorder: + put: + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Typed steer queue references + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.steerQueueReorderRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.steerQueueResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/apperror.Problem' + "403": + description: Forbidden + schema: + $ref: '#/definitions/apperror.Problem' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' + summary: Reorder accepted steer inputs + tags: + - sessions /bots/{bot_id}/sessions/events: get: description: |-