feat(web-shell): stream resumable BTW answers (#15709)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-27 12:19:00 +00:00
1 parent 6ec7e8765e
commit 6f53133d6d
45 files changed
+3949 -233

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# Web Shell BTW streaming
[English](web-shell-btw-streaming.md) | [简体中文](web-shell-btw-streaming.zh-CN.md)
## Problem and current behavior
Web Shell waits for the complete JSON BTW answer and gives up after 30 seconds.
The child and bridge also impose 55/60-second deadlines. Model output already
arrives in chunks but is accumulated internally. Users need visible partial
answers and simple recovery from temporary network disconnections.
## Proposed solution
Keep the existing `POST /session/:id/btw` API and its behavior unchanged. Add
`POST /session/:id/btw/stream` with `question`, a client-generated `requestId`,
and an optional `resume` flag. It emits SSE answer snapshots and terminal
`done` or `error` events. Every answer snapshot contains the accumulated answer;
the UI replaces its answer instead of appending, so reconnects cannot duplicate
text. Partial snapshots preserve whitespace; the final snapshot trims leading
and trailing whitespace to match the existing JSON BTW result. Reconnect uses the same request ID and receives the latest snapshot.
A resume with no retained request returns 410 and never starts another model call.
Reusing an ID for a different question is rejected.
The daemon retains one answer string per request, not an event journal. Network
disconnect only detaches the subscriber; generation continues. Explicit
`DELETE /session/:id/btw/stream/:requestId` cancels generation, including a
short-lived cancellation marker if cancellation reaches the server before start.
New requests have a 300-second daemon deadline, a 305-second child deadline,
and a 310-second bridge deadline, allowing the daemon to report `btw_timeout`
before upstream cleanup. If the BTW bridge deadline itself is reached, it also
sends a best-effort cancellation for that generation without cancelling the main task. Completed requests and cancellation markers remain
available for up to 60 seconds. Each runtime permits at most 32 retained requests;
admission evicts the earliest registered terminal entry when full, or returns
429 if all entries are active. Answers are limited to 1 MiB. Active requests
are never evicted to admit another request.
SSE uses existing 15-second heartbeats and backpressure-aware writes.
The SDK retries network errors and retryable HTTP responses with bounded backoff
and a 60-second reconnect budget. No new POST is started at or after the outage
deadline; exhaustion returns the last failure and still attempts explicit DELETE.
Until a successful HTTP response is observed,
retries use the same ID without `resume`; the live daemon cache deduplicates
an accepted request even if its headers were lost. After observing success,
retries use `resume: true`, so lost state fails with 410 instead of starting
another generation. This does not guarantee deduplication across daemon restarts.
It uses native REST fetch even when the main
session transport is ACP HTTP/WS. Cancellation interrupts reconnect waits and
sends the explicit DELETE. Application errors and expired requests terminate.
Web Shell bypasses its ordinary 30-second action deadline only for this stream.
The UI consumes answer/done snapshots without synthetic wire request IDs,
requires a terminal event, and preserves partial text when a stream fails,
while displaying the server error message through the existing notice. Both
SSE errors and HTTP errors preserve the server code in notice metadata; the
toast displays the message rather than a raw machine code.
The BTW panel follows new output by default, including delayed Markdown layout
changes. Scrolling up pauses following; returning within two pixels of the
bottom restores it. Every new question starts with following enabled.
The daemon advertises `session_btw_stream` only when every registered runtime
bridge implements the streaming method. The initial Qwen daemon bootstrap
advertises the same feature. Web Shell checks this independent flag before
starting each BTW: absent or unavailable capability information uses the
existing JSON request and its original 30-second action deadline, exposing the
result as a terminal UI snapshot. Once a stream starts, errors never trigger a
second JSON model request. SDK stream callers must preflight the same feature.
## Decisions and scope
Do not send BTW events through the main agent's shared/replayed session stream:
its consumers include transcript and other attached clients. Reuse the existing
request-scoped generation queue, notifications, cancellation, SSE helpers, and
forked-query context/tool restrictions instead. Add an internal BTW generation
purpose and a text callback to the cache-path fork helper; the legacy BTW method
continues using its original path. No event log, persistent storage, transport
redesign, new dependency, or daemon-restart recovery is needed. Streaming BTW
retains the existing SSH-workspace restriction; ordinary stateless generation
keeps its existing SSH behavior.
Both new routes are live-session-owner scoped. Existing owner resolution and
mutation/trust gates apply on every start, reconnect and cancel. In-memory state
is scoped by the resolved bridge identity, session, client and request ID; it
cannot cross workspaces or fall back to the primary runtime. In-flight side
requests count as local work so idle cleanup cannot interrupt disconnected
generation; the hold releases when generation settles. This deliberately applies
to all existing side requests, including legacy JSON BTW, recap, stateless
generation, fork launch, and workflow/goal actions that start work. They also
prevent required-idle bridge close while pending. Existing JSON route admission
and deadlines remain unchanged; explicit bridge close retains its normal rules. Shared archive admission
covers request validation and registration only, rather than the generation
lifetime, so intentional session close and daemon shutdown can abort pending
work even after the subscriber disconnects. A recorded client may
cancel its own cached request after detach; unknown cancellation markers still
require current session membership. User cancellation,
session/workspace changes, and superseding a BTW retain existing stale-result
guards and do not cancel or append to the main task.
Affected areas: core fork helper; ACP bridge request generation; CLI ACP handler
and owner-scoped routes/request snapshots; SDK BTW stream; Web Shell action,
answer panel and browser fixtures; focused unit and browser tests.
## Validation and acceptance criteria
- Preserve the old JSON response, deadlines and generic generation protocol;
apply side-request idle protection consistently to legacy BTW and generation.
- Advertise streaming support accurately; missing features/capabilities use only
JSON, advertised support uses only SSE, and stream failure never retries JSON.
- Show partial BTW answers before completion, including responses over 30 seconds;
verify original and resumed real HTTP streams before releasing model completion.
- Follow new output, pause while reading older text, resume at the bottom, and
reset following for a new question. Preserve partial answers on terminal failure.
- Keep main-task execution and transcript isolated while BTW runs.
- Disconnect/reconnect yields the latest answer without duplicate model work or text.
- Recover failures before response headers without a false 410 or duplicate live
generation. Reject expired accepted resumes and conflicting request IDs.
- Bound cache size and lifetime; test active-only saturation, terminal eviction,
exact answer-size limits, and retention boundaries.
- Close sessions and shut down promptly while disconnected BTW work is pending.
- Cancel while receiving or reconnecting; cancel-before-start cannot launch work.
- Retain owner-runtime/client isolation and input validation on all new routes.
- Run focused tests, build, typecheck, bundle and browser checks; inspect screenshots.
- Baseline/verification reports distinguish real daemon/ACP paths from model mocks.
## Open questions
None. Recovery across daemon restarts and prolonged disconnection beyond retention
is deliberately outside this change.
@@ -0,0 +1,98 @@
# Web Shell BTW 流式输出
[English](web-shell-btw-streaming.md) | [简体中文](web-shell-btw-streaming.zh-CN.md)
## 问题与当前行为
Web Shell 等待完整 JSON BTW 答案,并在 30 秒后超时。
子进程和 bridge 还分别限制 55/60 秒。模型已经分块输出,但内部累积后才返回。
用户需要及时看到部分答案,并在网络短暂断开后简单恢复。
## 方案
保持原有 `POST /session/:id/btw` 接口及行为不变。新增
`POST /session/:id/btw/stream`,接收 `question`、客户端生成的 `requestId`
及可选 `resume` 标记。接口通过 SSE 返回答案快照以及终结的 `done` 或 `error` 事件。
每个答案快照包含已累积的全文,UI 替换而非追加答案,避免重连后重复文本。
中途快照保留空白;最终快照裁去首尾空白,与既有 JSON BTW 结果一致。
重连复用同一请求 ID,并收到最新快照。恢复请求若已无缓存,返回 410,绝不重新调用模型。
同一 ID 携带不同问题会被拒绝。
daemon 每个请求只保留一个答案字符串,不保存事件日志。网络断开仅移除订阅者,
生成继续。显式 `DELETE /session/:id/btw/stream/:requestId` 取消生成;
若取消先于启动到达,短期保留取消标记,防止随后启动。
新请求的 daemon 总时限为 300 秒,子进程为 305 秒,bridge 为 310 秒,
让 daemon 先返回 `btw_timeout`,上游随后完成清理。若 BTW bridge 自身时限触发,
也会尽力取消该次生成,不取消主任务。已完成请求和取消标记最多保留 60 秒。
每个 runtime 最多保留 32 个请求;满额时淘汰最早登记的终态条目,全部活跃时返回 429。
答案上限为 1 MiB,不能为了接受新请求而淘汰活跃请求。
SSE 复用现有的 15 秒心跳和支持背压的写入方法。
SDK 对网络错误和可重试 HTTP 响应进行有上限的退避重连,重连预算为 60 秒。
离线预算到期时不再启动新的 POST,抛出最近的失败,并仍尽力发送显式 DELETE。
在观察到成功 HTTP 响应之前,重试复用请求 ID,不设置 `resume`;即使响应头丢失,
存活 daemon 的缓存仍会对已接受的请求去重。观察到成功响应后,重试设置 `resume: true`,
缓存丢失时返回 410,避免重新生成。这不保证跨 daemon 重启去重。
即使主会话使用 ACP HTTP/WS,也使用原生 REST fetch。
取消会中断重连等待并显式发送 DELETE。应用错误和过期请求直接结束。
Web Shell 仅对该流绕过普通 action 的 30 秒总时限。
UI 消费只包含答案和终态的本地快照,不伪造协议请求 ID,并要求流以终态结束。
流失败时保留已显示的部分文本,通过现有错误通知展示服务端错误信息。
SSE 和 HTTP 错误的服务端错误码均保存在 notice 元数据中;toast 展示错误信息而非机器码。
BTW 面板默认跟随最新内容,也覆盖 Markdown 延迟布局变化。用户向上滚动时暂停跟随,
回到距底部两像素以内时恢复。每个新问题都重新启用跟随。
daemon 仅在所有已注册 runtime 的 bridge 都实现流式方法时声明
`session_btw_stream`;Qwen daemon 的初始启动窗口也声明相同标识。
Web Shell 每次 BTW 都先检查这一独立标识:标识缺失或能力信息尚未取得时,
只调用既有 JSON 请求并保留原来的 30 秒 action 时限,将结果转换为 UI 的完成快照。
流式请求一旦启动,失败时绝不再发起第二次 JSON 模型请求。
SDK 流式接口调用方也必须预检同一 feature。
## 决策与范围
BTW 不经过主 agent 共享且支持回放的会话事件流,避免影响 transcript 和其他客户端。
复用现有请求级生成队列、通知、取消、SSE 工具,以及 fork 查询的上下文和工具限制。
内部增加 BTW 生成用途,并在 cache-path fork helper 增加文本回调;旧 BTW 方法继续原有路径。
无需事件日志、持久化存储、传输层重构、新依赖或 daemon 重启恢复。
流式 BTW 保留既有 SSH workspace 限制,普通无状态生成保留原来的 SSH 行为。
两个新路由均属于 live-session-owner 范围。每次启动、重连及取消都经过现有 owner
解析和 mutation/trust 门禁。内存状态按解析后的 bridge 实例、session、client 和请求 ID
隔离,不能跨 workspace,也不能回退到主 runtime。正在执行的 side request 计入本地活动,
避免空闲回收中断断线期间的生成;生成结束时释放占用。这有意覆盖所有既有 side request,
包括旧 JSON BTW、recap、无状态生成、fork 启动及会启动工作的 workflow/goal 操作。
它们待完成时也阻止要求空闲的 bridge 关闭。旧 JSON 路由的门禁与时限保持不变,
显式 bridge 关闭仍按既有规则执行。共享归档门禁仅覆盖校验及登记,
不覆盖完整生成周期,确保订阅者断线后主动关闭会话及 daemon 退出仍能中止待完成工作。
已记录的客户端在 detach 后
仍可取消自己的缓存请求;创建未知请求的取消标记仍要求当前会话成员身份。
用户取消、切换 session/workspace 或发起新 BTW 时,保留原有旧结果防护,
不得取消主任务或向主对话追加内容。
涉及范围:core fork helper、ACP bridge 请求生成、CLI ACP handler 及 owner 路由/请求快照、
SDK BTW 流、Web Shell action/答案面板/浏览器 fixture,以及针对性的单元和浏览器测试。
## 验证与验收标准
- 保持旧 JSON 响应、时限和现有通用生成协议;旧 BTW 和通用生成一致使用 side request 空闲保护。
- 如实声明流式能力;缺少 feature 或 capabilities 时仅走 JSON,有标识时仅走 SSE,
流失败后不得再重试 JSON。
- 完成前显示部分答案,支持超过 30 秒的响应;真实 HTTP 原连接及恢复连接
均须在释放模型完成门之前收到部分内容。
- 默认跟随输出,向上阅读时暂停,回到底部恢复,新问题重置跟随;终态失败保留部分答案。
- BTW 运行时保持主任务执行和 transcript 隔离。
- 断线重连返回最新答案,不重复调用模型或拼接文本。
- 响应头到达前的网络失败可重试,不误报 410 或重复存活请求的模型调用;
已接受请求的恢复若过期则拒绝,同时拒绝请求 ID 冲突。
- 限制缓存大小及生命周期,覆盖全活跃满额、终态淘汰、精确答案大小及保留期边界。
- BTW 断线后仍在生成时,关闭会话和 daemon 退出应及时完成。
- 接收中或重连中均可取消;取消先到时不得启动生成。
- 新路由保留 owner runtime/client 隔离及输入验证。
- 运行针对性测试、build、typecheck、bundle 和浏览器验证,目视检查截图。
- 基线及验证报告区分真实 daemon/ACP 链路与模拟模型。
## 待定问题
无。daemon 重启恢复以及超过缓存保留期的长期离线恢复不在本次范围内。
+1 -1
View File
@@ -94,7 +94,7 @@ export class MyChannel extends ChannelBase {
Most adapters should pass `options` through unchanged. If an adapter creates its own `SessionRouter` and passes that router to `super()`, set `registerBridgeEvents: true` in `ChannelBaseOptions` so `ChannelBase` still receives `toolCall` and `sessionDied` events directly. Leave it unset for routers supplied by the channel gateway.
If your adapter exposes shell-command or BTW side-question behavior, check that the corresponding `bridge.shellCommand` / `bridge.btw` method exists before enabling it. Daemon-managed workers omit those optional methods unless the daemon advertises the matching `session_shell_command` / `session_btw` capability.
If your adapter exposes shell-command or BTW side-question behavior, check that the corresponding `bridge.shellCommand` / `bridge.btw` method exists before enabling it. Daemon-managed workers omit those optional methods unless the daemon advertises the matching `session_shell_command` / `session_btw` capability. These channel-adapter methods cover the existing JSON side-question API. Direct daemon clients that use SSE must separately check `session_btw_stream` before calling `streamSessionBtw`; the daemon advertises it only when every registered runtime ACP session bridge implements `generateSessionBtwStream`. The existing `bridge.btw` adapter contract does not imply streaming support.
## The Envelope
@@ -130,6 +130,7 @@ operations when they share ownership and an SDK family.
| Session organization | [`GET /workspace/:id/session-groups`](./qwen-serve-protocol.md#get-workspaceidsession-groups) · [`POST /workspace/:id/session-groups`](./qwen-serve-protocol.md#post-workspaceidsession-groups) · [`PATCH /workspace/:id/session-groups/:groupId`](./qwen-serve-protocol.md#patch-workspaceidsession-groupsgroupid) · [`DELETE /workspace/:id/session-groups/:groupId`](./qwen-serve-protocol.md#delete-workspaceidsession-groupsgroupid) · [`PATCH /session/:id/organization`](./qwen-serve-protocol.md#patch-sessionidorganization-and-patch-workspacesworkspacesessionidorganization) · [`PATCH /workspaces/:workspace/session/:id/organization`](./qwen-serve-protocol.md#patch-sessionidorganization-and-patch-workspacesworkspacesessionidorganization) | `session_organization`; legacy-primary or persisted-workspace | `DaemonClient.listSessionGroups`, `createSessionGroup`, `updateSessionGroup`, `deleteSessionGroup`, `updateSessionOrganization`; `WorkspaceDaemonClient.updateSessionOrganization` |
| Bulk persisted-session changes | [`POST /sessions/delete`](./qwen-serve-protocol.md#post-sessionsdelete) · [`POST /sessions/archive`](./qwen-serve-protocol.md#post-sessionsarchive) · [`POST /sessions/unarchive`](./qwen-serve-protocol.md#post-sessionsunarchive) | `session_archive`; legacy-primary | `DaemonClient.deleteSessionsData`, `archiveSessionsData`, `unarchiveSessionsData` |
| Message deletion | [`GET /session/:id/message-groups`](./qwen-serve-protocol.md#get-sessionidmessage-groups) · [`POST /session/:id/messages/delete`](./qwen-serve-protocol.md#post-sessionidmessagesdelete) | `session_message_deletion`; live-session-owner | `DaemonClient.getMessageGroups`, `DaemonClient.deleteMessageGroups` |
| Streaming side questions | [`POST /session/:id/btw/stream`](./qwen-serve-protocol.md#post-sessionidbtwstream) · [`DELETE /session/:id/btw/stream/:requestId`](./qwen-serve-protocol.md#delete-sessionidbtwstreamrequestid) | `session_btw_stream`; live-session-owner | `DaemonClient.streamSessionBtw`, raw REST for cancellation |
| Optional session controls | [`POST /session/:id/recap`](./qwen-serve-protocol.md#post-sessionidrecap) · [`POST /session/:id/generate`](./qwen-serve-protocol.md#post-sessionidgenerate) · [`POST /session/:id/approval-mode`](./qwen-serve-protocol.md#post-sessionidapproval-mode) | `session_recap`, `session_generation`, `session_approval_mode_control`; live-session-owner | `DaemonClient.recapSession`, raw REST for generation, `DaemonClient.setSessionApprovalMode` |
| Workspace configuration | [`POST /workspace/tools/:name/enable`](./qwen-serve-protocol.md#post-workspacetoolsnameenable) · [`POST /workspace/skills/:name/enable`](./qwen-serve-protocol.md#post-workspaceskillsnameenable) · [`POST /workspace/skills/enable`](./qwen-serve-protocol.md#post-workspaceskillsenable) · [`POST /workspace/init`](./qwen-serve-protocol.md#post-workspaceinit) · [`POST /workspace/mcp/reload`](./qwen-serve-protocol.md#post-workspacemcpreload) · [`POST /workspace/mcp/:server/restart`](./qwen-serve-protocol.md#post-workspacemcpserverrestart) · [`POST /language`](./qwen-serve-protocol.md#post-language) | `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_toggle`, `workspace_init`, `workspace_mcp_manage`, `workspace_mcp_restart`, `user_language_sync`; legacy-primary or process-global | `DaemonClient.setWorkspaceToolEnabled`, `setWorkspaceSkillEnabled`, `setWorkspaceSkillsEnabled`, `initWorkspace`, `reloadWorkspaceMcp`, `restartMcpServer`, `setUserLanguage` |
| Skill runtime refresh | [`POST /workspace/skills/refresh`](./qwen-serve-protocol.md#post-workspaceskillsrefresh) | `workspace_session_refresh`; legacy-primary | `DaemonClient.refreshWorkspaceSkills` |
+27 -1
View File
@@ -194,6 +194,32 @@ cache path for a single-turn, no-tool LLM call and returns
`{ sessionId, answer: string | null }`. The implementation enforces
`BTW_MAX_INPUT_LENGTH`, cross-session leakage guards, and timeout handling.
### Streaming BTW (`session_btw_stream` capability tag)
`POST /session/:id/btw/stream` accepts `{ question, requestId, resume? }` and
emits accumulated `answer` snapshots and terminal `done` or `error` events.
Check this independent capability before calling; an absent flag uses the
existing JSON BTW API. Once streaming starts, errors must not trigger a second
JSON request. The route resolves the live session owner and its trust boundary.
Disconnecting a subscriber keeps generation running. Reuse the same request ID,
question and client identity to receive the latest retained snapshot. The SDK
retries without `resume` until a successful HTTP response was observed, then
uses `resume: true`; missing retained state on resume returns 410 and cannot
start another generation. Retention is up to 60 seconds and the runtime cache
holds at most 32 entries, evicting the earliest registered terminal entry when
needed. Active entries are not evicted. `DELETE /session/:id/btw/stream/:requestId`
explicitly cancels the matching generation. Neither route interrupts the main
prompt. See the [wire contract](../qwen-serve-protocol.md#post-sessionidbtwstream)
for input limits, deadlines and error codes.
Side requests, including legacy BTW, recap and stateless generation, count as
local work and prevent idle cleanup or required-idle close until they settle.
This does not change each route's existing archive admission rules. Streaming
BTW holds shared archive admission
only during request registration, so an abandoned subscriber cannot keep that
admission held for the generation lifetime.
### Shell Command Execution
`POST /session/:id/shell` executes a shell command directly on the daemon host,
@@ -358,7 +384,7 @@ resets that window, including when the channel was already live.
timeout.
- `BridgeOptions.sessionRestoreTimeoutMs` (default 60s) — ACP `loadSession` / `unstable_resumeSession` deadline. Defaults to 60s; an explicitly configured initialize timeout can raise it, but never lower it.
- `BridgeOptions.channelIdleTimeoutMs` (unset or `0` reaps after runtime work drains, except that plain preheat is preserved for first use; a positive value or active keepalive delays reaping, and the longer delay wins).
- Capability tags: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_info`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_generation`, `session_btw`, `session_context_usage`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `non_blocking_prompt`.
- Capability tags: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_info`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_generation`, `session_btw`, `session_btw_stream`, `session_context_usage`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `non_blocking_prompt`.
### Stateless generation (`session_generation` capability tag)
@@ -109,7 +109,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T
Foundation: `health`, `daemon_status`, `capabilities`.
Sessions: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_mid_turn_send_now`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_archive_idle_guard`, `session_storage_conflict_repair`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, **`user_language_sync`** (conditional), `session_rewind`, `session_hooks`, `session_branch`.
Sessions: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_mid_turn_send_now`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_archive_idle_guard`, `session_storage_conflict_repair`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_btw_stream`** (conditional), **`session_shell_command`** (conditional), `session_language`, **`user_language_sync`** (conditional), `session_rewind`, `session_hooks`, `session_branch`.
Streaming: `slow_client_warning`, `typed_event_schema`.
+84 -2
View File
@@ -234,7 +234,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'extension_batch_activation_v2', 'extension_activation_explicit_refresh',
'extension_state',
'workspace_settings', 'workspace_init', 'workspace_mcp_restart',
'session_recap', 'session_generation', 'session_btw', 'session_shell_command',
'session_recap', 'session_generation', 'session_btw', 'session_btw_stream', 'session_shell_command',
'standalone_sessions_v1', 'standalone_session_options_v1',
'mcp_workspace_pool', 'mcp_pool_restart',
'require_auth', 'allow_origin', 'auth_device_flow',
@@ -674,6 +674,7 @@ nothing.
| `workspace_artifact_library_stream` | Session metadata on all artifact-library reads and opt-in finite SSE for full and selected-session queries; requires artifact persistence. |
| `session_sources` | session source persistence is wired for the runtime. Registers metadata-only workspace files, uploaded attachments, and HTTP(S) links through the live session owner. |
| `session_generation` | session generation helpers are available. |
| `session_btw_stream` | Every registered runtime bridge supports resumable BTW streaming. Clients must check this flag before calling `/session/:id/btw/stream`; otherwise use the existing JSON BTW interface. |
| `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. |
| `workspace_generation` | workspace-scoped generation helpers are available. |
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
@@ -3555,7 +3556,7 @@ ACP-over-HTTP uses the same request and response bodies through vendor methods `
### Multi-workspace live-session routing
When `multi_workspace_sessions` is advertised, live-session operations identify their workspace from the `sessionId`; clients do not add a workspace selector to the URL. In addition to the existing owner-routed lifecycle operations, this applies to `PATCH /session/:id/metadata`, `POST /session/:id/recap`, `POST /session/:id/generate`, `POST /session/:id/btw`, `POST /session/:id/mid-turn-message`, `GET /session/:id/mid-turn-messages`, `POST /session/:id/mid-turn-messages/send-now`, `DELETE /session/:id/mid-turn-messages/:messageId`, `POST /session/:id/tasks/:taskId/cancel`, `POST /session/:id/goal/clear`, `POST /session/:id/continue`, `POST /session/:id/language`, `POST /session/:id/artifacts`, `DELETE /session/:id/artifacts/:artifactId`, `GET /session/:id/sources`, `POST /session/:id/sources`, and `DELETE /session/:id/sources/:sourceId`. The daemon routes each request to the trusted runtime that owns the live session. An untrusted non-primary owner returns `403 untrusted_workspace`, a missing live owner returns `404 session_not_found`, and an ambiguous owner fails closed with `500 ambiguous_session_owner`.
When `multi_workspace_sessions` is advertised, live-session operations identify their workspace from the `sessionId`; clients do not add a workspace selector to the URL. In addition to the existing owner-routed lifecycle operations, this applies to `PATCH /session/:id/metadata`, `POST /session/:id/recap`, `POST /session/:id/generate`, `POST /session/:id/btw`, `POST /session/:id/btw/stream`, `DELETE /session/:id/btw/stream/:requestId`, `POST /session/:id/mid-turn-message`, `GET /session/:id/mid-turn-messages`, `POST /session/:id/mid-turn-messages/send-now`, `DELETE /session/:id/mid-turn-messages/:messageId`, `POST /session/:id/tasks/:taskId/cancel`, `POST /session/:id/goal/clear`, `POST /session/:id/continue`, `POST /session/:id/language`, `POST /session/:id/artifacts`, `DELETE /session/:id/artifacts/:artifactId`, `GET /session/:id/sources`, `POST /session/:id/sources`, and `DELETE /session/:id/sources/:sourceId`. The daemon routes each request to the trusted runtime that owns the live session. An untrusted non-primary owner returns `403 untrusted_workspace`, a missing live owner returns `404 session_not_found`, and an ambiguous owner fails closed with `500 ambiguous_session_owner`.
This rule is live-session-only and does not make every workspace-less session route multi-workspace-aware. Persisted or archived operations use their documented workspace-qualified routes. `POST /session/:id/branch`, `POST /session/:id/fork`, and `POST /session/:id/cd` intentionally remain primary-only and return `non_primary_session_route_not_supported` for non-primary owners.
@@ -3985,6 +3986,87 @@ content. A model failure after streaming starts produces an `error` event; it
does not retry with another model. Prompts are limited to 32 KiB of UTF-8 text.
Disconnecting the HTTP client cancels the generation request.
### `POST /session/:id/btw/stream`
Capability tag: `session_btw_stream`. Scope: live-session-owner.
Ask a tool-free side question using the live session's conversation context,
without appending to its transcript or interrupting its main task. The existing
JSON `POST /session/:id/btw` API remains available with its original behavior.
Request:
```json
{ "question": "Why?", "requestId": "unique-request-id", "resume": false }
```
`question` is a non-empty trimmed string, limited to 4096 characters before
trimming. `requestId` contains 1–64 ASCII letters, digits, underscores or hyphens.
`resume` is optional and defaults to `false`. `X-Qwen-Client-Id`, when supplied,
must name a client registered with the live session. Every start and resume
uses the resolved owner runtime and its trust and mutation gates.
The response is `text/event-stream`, with `v: 1` on every event:
| Event | Payload |
| -------- | --------------------------------------------------------------------------- |
| `answer` | `{ "type": "answer", "requestId": "...", "answer": "accumulated text" }` |
| `done` | `{ "type": "done", "requestId": "...", "answer": "trimmed complete text" }` |
| `error` | `{ "type": "error", "requestId": "...", "code": "...", "message": "..." }` |
Each `answer` replaces the previous text; it is not a delta to append. Partial
snapshots preserve whitespace; `done` trims leading and trailing whitespace,
matching the existing JSON BTW result. The initial snapshot can be empty. `done` and `error` terminate the stream. Heartbeat
comments are sent every 15 seconds. An answer is limited to 1 MiB of UTF-8 text;
exceeding it produces `btw_failed`. The daemon ends generation after five minutes
with `btw_timeout`; child and bridge cleanup deadlines allow another five and ten
seconds respectively. Explicit cancellation produces `btw_cancelled`; other
upstream failures produce `btw_failed`.
HTTP disconnection only detaches the subscriber. Reconnect with the same
`requestId`, question and client identity and `resume: true` to receive the latest
snapshot without starting another model call. Repeat initial submissions with
the same identity also reuse retained work. No event cursor or `Last-Event-ID`
is required. A `resume: true` request whose retained entry is missing returns
410 and never starts new work. Requests are scoped by
bridge identity, session, client and request ID; they cannot resume across a
daemon restart or replacement owner runtime.
There are at most 32 retained requests per owner runtime. Terminal receipts and
cancellation markers expire after 60 seconds and may be evicted earlier, oldest
first, to admit a new request. Active requests are not evicted. Generation does
not hold a session's shared archive admission lock while waiting for the model
or subscriber; bridge activity still protects it against idle cleanup. Explicit
session close and daemon shutdown retain their normal cancellation behavior.
Errors before SSE starts:
- `400 invalid_btw_request` — invalid question, request ID or resume flag.
- `400 invalid_client_id` — malformed or unregistered client identity.
- `403 untrusted_workspace` — the resolved non-primary owner is untrusted.
- `404 session_not_found` — no live owner exists.
- `409 btw_request_conflict` — the retained ID belongs to another question.
- `410 btw_expired` — a resume has no retained request.
- `429 btw_limit` — all 32 retained requests are active.
- `501 btw_stream_not_supported` — the owner's bridge cannot stream BTW.
### `DELETE /session/:id/btw/stream/:requestId`
Capability tag: `session_btw_stream`. Scope: live-session-owner.
Cancel the matching BTW request with the same session and client identity used
to start it. Returns empty `204`; cancellation is idempotent for a retained
request. The exact cached identity can cancel after its client detached, while
unknown IDs require a still-registered client. Cancelling an unknown ID records
a short-lived cancellation marker so a delayed initial POST cannot start work.
The route uses the same owner resolution, trust and mutation gates as streaming
POST. Invalid IDs return `400 invalid_btw_request`; invalid client identities
return `400 invalid_client_id`. An unknown cancellation returns `429 btw_limit`
when all 32 retained requests are active. A known active request can always be
cancelled even when the runtime is at capacity. Cancellation does not cancel the
main prompt or another session's BTW.
### Mutation: approval, tools, skills, init, MCP restart
The daemon exposes five mutation control routes that let remote clients change runtime posture without touching the daemon host's CLI. Approval-mode control retains its non-strict compatibility gate. Tool toggle, skill toggle, workspace init, and MCP restart use the strict mutation gate: trusted-loopback primary, bearer-authenticated, and paired Local Control requests pass. A token-less primary request that reaches the gate without trusted-loopback authority receives `401 {code: 'token_required'}`; missing or invalid configured credentials and unpaired Local Control credentials are rejected earlier with plain `401 Unauthorized`. All five:
@@ -496,6 +496,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_generation',
'workspace_generation',
'session_btw',
'session_btw_stream',
'mcp_workspace_pool',
'mcp_pool_restart',
'auth_device_flow',
+8
View File
@@ -2867,6 +2867,14 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
context?: BridgeClientRequestContext,
): AsyncIterable<BridgeGenerationStreamEvent>;
/** Stream a tool-free side answer using the session's conversation context. */
generateSessionBtwStream?(
sessionId: string,
question: string,
signal: AbortSignal,
context?: BridgeClientRequestContext,
): AsyncIterable<BridgeGenerationStreamEvent>;
/**
* Run a side question (/btw) against the session's conversation context.
* Uses runForkedAgent (cache path) for a single-turn, tool-free LLM call.
+296 -67
View File
@@ -22,6 +22,7 @@ import type {
import { SESSION_EXECUTION_ENGINE_META_KEY } from './bridgeOptions.js';
import {
ACTIVE_WORK_CLOSE_TIMEOUT_MS,
ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM,
ACTIVE_WORK_HEARTBEAT_INTERVAL_MS,
ACTIVE_WORK_HEARTBEAT_META_KEY,
ACTIVE_WORK_HEARTBEAT_VERSION,
@@ -848,48 +849,263 @@ describe('ACP Bridge execution engines', () => {
},
);
it('ignores generation events from a foreign connection', async () => {
it.each(['generateSessionContent', 'generateSessionBtwStream'] as const)(
'%s ignores generation events from a foreign connection',
async (method) => {
const completion = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
extMethodImpl: () => completion.promise,
});
const p = paired({}, engineChannel('legacy'), managed);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
p.choose('legacy');
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
const stream = p.bridge[method]!(
session.sessionId,
'generate',
new AbortController().signal,
);
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(1),
);
const requestId = managed.agent.extMethodCalls[0].params['requestId'];
expect(managed.agent.extMethodCalls[0]).toEqual({
method: SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
params: {
sessionId: session.sessionId,
requestId,
prompt: 'generate',
...(method === 'generateSessionBtwStream' ? { purpose: 'btw' } : {}),
},
});
try {
for (const [channel, model] of [
[p.legacy, 'foreign model'],
[managed, 'owner model'],
] as const) {
await channel.agentConnection.extNotification(
'qwen/notify/session/generation/event',
{
v: 1,
sessionId: session.sessionId,
requestId,
event: { type: 'started', model, modelSource: 'main' },
},
);
}
} finally {
completion.resolve({ model: 'owner model', modelSource: 'main' });
}
const events = [];
for await (const event of stream) events.push(event);
expect(events).toEqual([
{
type: 'started',
requestId,
model: 'owner model',
modelSource: 'main',
},
{ type: 'done', requestId, model: 'owner model', modelSource: 'main' },
]);
},
);
it('authorizes the BTW stream before dispatching to the session owner', async () => {
const p = paired();
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(() =>
p.bridge.generateSessionBtwStream!(
session.sessionId,
'question',
new AbortController().signal,
{ clientId: 'foreign-client' },
),
).toThrow('is not registered');
expect(() =>
p.bridge.generateSessionBtwStream!(
'unknown-session',
'question',
new AbortController().signal,
),
).toThrow(SessionNotFoundError);
expect(p.managed.agent.extMethodCalls).toHaveLength(0);
});
it.each([
'generateSessionContent',
'generateSessionBtwStream',
'generateSessionBtw',
] as const)(
'%s prevents idle close until generation settles after detach',
async (method) => {
vi.useFakeTimers();
const completion = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
initializeImpl: () => activeWorkInitialize(),
extMethodImpl: (extMethod) =>
extMethod === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart ||
extMethod === SERVE_CONTROL_EXT_METHODS.sessionBtw
? completion.promise
: { closed: true, holds: [] },
});
const p = paired(
{ sessionReapIntervalMs: 10, sessionIdleTimeoutMs: 10 },
engineChannel('legacy'),
managed,
);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
await reportActiveWork(managed, 1, [
{ sessionId: session.sessionId, holds: [] },
]);
expect(
p.bridge.getSessionSummary(session.sessionId).activeWorkState,
).toBe('idle');
const pending = p.bridge[method]!(
session.sessionId,
'question',
new AbortController().signal,
);
const consuming = (async () => {
if ('then' in pending)
expect(await pending).toMatchObject({ answer: 'side answer' });
else for await (const event of pending) expect(event.type).toBe('done');
})();
try {
await vi.advanceTimersByTimeAsync(0);
expect(
p.bridge.getSessionSummary(session.sessionId).activeWorkState,
).toBe('active');
await expect(
p.bridge.closeSession(session.sessionId, undefined, {
requireIdle: true,
}),
).rejects.toMatchObject({ name: 'SessionBusyError' });
await p.bridge.detachClient(session.sessionId, session.clientId);
await vi.advanceTimersByTimeAsync(40);
expect(p.bridge.sessionCount).toBe(1);
expect(
managed.agent.extMethodCalls.filter(
(call) => call.method === SERVE_CONTROL_EXT_METHODS.sessionClose,
),
).toHaveLength(0);
} finally {
completion.resolve({
model: 'main',
modelSource: 'main',
sessionId: session.sessionId,
answer: 'side answer',
});
await consuming;
}
await flushWithoutTime();
expect(p.bridge.sessionCount).toBe(0);
expect(
managed.agent.extMethodCalls.filter(
(call) =>
call.method === SERVE_CONTROL_EXT_METHODS.sessionClose &&
call.params[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] === true,
),
).toHaveLength(1);
},
);
it('cancels only its BTW generation and drops subsequent output', async () => {
const completion = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
extMethodImpl: (method) =>
method === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart
? completion.promise
: {},
});
const p = paired({}, engineChannel('legacy'), managed);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
const controller = new AbortController();
const stream = p.bridge.generateSessionBtwStream!(
session.sessionId,
'question',
controller.signal,
{ clientId: session.clientId },
);
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(1),
);
const requestId = managed.agent.extMethodCalls[0].params['requestId'];
controller.abort();
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(2),
);
expect(managed.agent.extMethodCalls[1]).toEqual({
method: SERVE_CONTROL_EXT_METHODS.sessionGenerationCancel,
params: { sessionId: session.sessionId, requestId },
});
await managed.agentConnection.extNotification(
'qwen/notify/session/generation/event',
{
v: 1,
sessionId: session.sessionId,
requestId,
event: { type: 'delta', seq: 0, text: 'stale answer' },
},
);
completion.resolve({ model: 'test', modelSource: 'main' });
const events = [];
for await (const event of stream) events.push(event);
expect(events).toEqual([]);
expect(managed.agent.cancelCalls).toEqual([]);
});
it.each([
['generateSessionContent', 65_000],
['generateSessionBtwStream', 310_000],
] as const)('%s retains its own bridge deadline', async (method, timeout) => {
const completion = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
extMethodImpl: () => completion.promise,
});
const p = paired({}, engineChannel('legacy'), managed);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
p.choose('legacy');
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
const stream = p.bridge.generateSessionContent!(
vi.useFakeTimers();
const stream = p.bridge[method]!(
session.sessionId,
'generate',
'question',
new AbortController().signal,
);
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(1),
const result = stream[Symbol.asyncIterator]()
.next()
.catch((error: unknown) => error);
let settled = false;
void result.then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(timeout - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(await result).toMatchObject({
name: 'BridgeTimeoutError',
timeoutMs: timeout,
});
const starts = managed.agent.extMethodCalls.filter(
(call) =>
call.method === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
);
const requestId = managed.agent.extMethodCalls[0].params['requestId'];
try {
for (const [channel, model] of [
[p.legacy, 'foreign model'],
[managed, 'owner model'],
] as const) {
await channel.agentConnection.extNotification(
'qwen/notify/session/generation/event',
{
v: 1,
const cancels = managed.agent.extMethodCalls.filter(
(call) =>
call.method === SERVE_CONTROL_EXT_METHODS.sessionGenerationCancel,
);
if (method === 'generateSessionBtwStream') {
expect(cancels).toEqual([
{
method: SERVE_CONTROL_EXT_METHODS.sessionGenerationCancel,
params: {
sessionId: session.sessionId,
requestId,
event: { type: 'started', model, modelSource: 'main' },
requestId: starts[0].params['requestId'],
},
);
}
} finally {
completion.resolve({ model: 'owner model', modelSource: 'main' });
}
const events = [];
for await (const event of stream) events.push(event);
expect(events).toEqual([
{ type: 'started', requestId, model: 'owner model', modelSource: 'main' },
{ type: 'done', requestId, model: 'owner model', modelSource: 'main' },
]);
},
]);
expect(managed.agent.cancelCalls).toEqual([]);
} else expect(cancels).toEqual([]);
completion.resolve({ model: 'test', modelSource: 'main' });
});
it('reserves a generated ID while a rejected receipt is being cleaned up', async () => {
@@ -3243,44 +3459,47 @@ describe('paired quarantine recovery', () => {
await vi.waitFor(() => expect(managed.killed).toBe(true));
});
it('keeps a session while its content generation runs', async () => {
const generated = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
newSessionImpl: (_request, agent) => ({
sessionId:
agent.newSessionCalls.length === 2
? ''
: `managed-${agent.newSessionCalls.length}`,
...receipt('managed'),
}),
extMethodImpl: (method) =>
method === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart
? generated.promise
: method === SERVE_CONTROL_EXT_METHODS.sessionClose
? { closed: true }
: {},
});
const p = paired({}, engineChannel('legacy'), managed);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
const stream = p.bridge.generateSessionContent!(
session.sessionId,
'generate',
new AbortController().signal,
);
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(1),
);
await expect(
p.bridge.spawnOrAttach({ workspaceCwd: WS_A }),
).rejects.toThrow('invalid');
expect(p.bridge.getSessionSummary(session.sessionId)).toBeDefined();
it.each(['generateSessionContent', 'generateSessionBtwStream'] as const)(
'keeps a session while %s runs',
async (method) => {
const generated = deferred<Record<string, unknown>>();
const managed = engineChannel('managed', {
newSessionImpl: (_request, agent) => ({
sessionId:
agent.newSessionCalls.length === 2
? ''
: `managed-${agent.newSessionCalls.length}`,
...receipt('managed'),
}),
extMethodImpl: (method) =>
method === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart
? generated.promise
: method === SERVE_CONTROL_EXT_METHODS.sessionClose
? { closed: true }
: {},
});
const p = paired({}, engineChannel('legacy'), managed);
const session = await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
const stream = p.bridge[method]!(
session.sessionId,
'generate',
new AbortController().signal,
);
await vi.waitFor(() =>
expect(managed.agent.extMethodCalls).toHaveLength(1),
);
await expect(
p.bridge.spawnOrAttach({ workspaceCwd: WS_A }),
).rejects.toThrow('invalid');
expect(p.bridge.getSessionSummary(session.sessionId)).toBeDefined();
generated.resolve({ model: 'test', modelSource: 'main' });
const events: string[] = [];
for await (const event of stream) events.push(event.type);
expect(events).toEqual(['done']);
await vi.waitFor(() => expect(managed.killed).toBe(true));
});
generated.resolve({ model: 'test', modelSource: 'main' });
const events: string[] = [];
for await (const event of stream) events.push(event.type);
expect(events).toEqual(['done']);
await vi.waitFor(() => expect(managed.killed).toBe(true));
},
);
it('lets background jobs that finish during a quarantine settle', async () => {
const managed = engineChannel('managed', {
@@ -4940,6 +5159,16 @@ describe('workspace change propagation', () => {
context,
),
],
[
'streamed side question',
() =>
p.bridge.generateSessionBtwStream!(
sessionId,
'why?',
new AbortController().signal,
context,
),
],
[
'fork agent',
() =>
+117 -89
View File
@@ -2366,6 +2366,7 @@ const DAEMON_CONFIRM_CANCELLATION_META_KEY = 'qwen.daemon.confirmCancellation';
*/
const SESSION_RECAP_TIMEOUT_MS = 60_000;
const SESSION_GENERATION_TIMEOUT_MS = 65_000;
const SESSION_BTW_STREAM_TIMEOUT_MS = 310_000;
const GENERATION_STREAM_QUEUE_CAPACITY = 128;
const SESSION_BTW_TIMEOUT_MS = 60_000;
const SESSION_TRANSCRIPT_TIMEOUT_MS = 60_000;
@@ -2946,7 +2947,7 @@ export function createSessionControlPlane(
/**
* Daemon-owned facts only: prompts this daemon accepted (queued *or*
* dispatched) and notifications it is currently pushing into the child.
* dispatched), side requests and notifications in flight to the child.
* These never depend on the child reporting anything, which is why they
* stay authoritative for a channel that never negotiated.
*/
@@ -2955,6 +2956,7 @@ export function createSessionControlPlane(
(entry.mcpAppCalls?.size ?? 0) > 0 ||
entry.pendingPromptCount > 0 ||
entry.pendingAgentNotificationCount > 0 ||
entry.sideRequestCount > 0 ||
!!entry.backgroundTurn
);
}
@@ -3901,7 +3903,6 @@ export function createSessionControlPlane(
!entryHasLocalWork(entry) &&
!entry.promptActive &&
!entry.goalTurnActive &&
entry.sideRequestCount === 0 &&
entry.hasRunningBackgroundTasks !== true &&
!childReportsHeldWork(entry)
);
@@ -3943,7 +3944,7 @@ export function createSessionControlPlane(
/**
* Admit a request for a Session that is not a turn but still starts work in
* the child, the way a prompt is admitted, and count it until it answers so
* a quarantine drain does not close the Session under it.
* idle cleanup and quarantine drains do not close the Session under it.
*/
function beginSideRequest(entry: SessionEntry): () => void {
assertChannelAdmitsPrompts(entry);
@@ -3954,6 +3955,7 @@ export function createSessionControlPlane(
ended = true;
entry.sideRequestCount = Math.max(0, entry.sideRequestCount - 1);
drainQuarantinedChannelFor(entry);
void maybeCloseIdleSession(entry, 'side_request_settled');
};
}
@@ -10423,6 +10425,109 @@ export function createSessionControlPlane(
// runtime's configuration.
const runtimeMcpServerNames = new Set<string>();
let runtimeMcpServerChannelId: string | undefined;
function startSessionGeneration(
sessionId: string,
prompt: string,
signal: AbortSignal,
context?: BridgeClientRequestContext,
purpose?: 'btw',
): AsyncIterable<BridgeGenerationStreamEvent> {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const info = channelInfoForEntry(entry);
if (!info || info.harness.isDying)
throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
const endSideRequest = beginSideRequest(entry);
const requestId = randomUUID();
const queue = new GenerationStreamQueue<BridgeGenerationStreamEvent>(
GENERATION_STREAM_QUEUE_CAPACITY,
);
const request = {
sessionId,
connection: entry.connection,
queue,
settled: false,
};
generationRequests.set(requestId, request);
const cancel = () => {
if (request.settled) return;
request.settled = true;
generationRequests.delete(requestId);
queue.close();
void entry.connection
.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGenerationCancel, {
sessionId,
requestId,
})
.catch(() => undefined);
};
signal.addEventListener('abort', cancel, { once: true });
if (signal.aborted) {
cancel();
endSideRequest();
return queue;
}
void Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
{ sessionId, requestId, prompt, ...(purpose ? { purpose } : {}) },
),
purpose === 'btw'
? SESSION_BTW_STREAM_TIMEOUT_MS
: SESSION_GENERATION_TIMEOUT_MS,
SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
),
getTransportClosedReject(entry),
])
.then((raw) => {
if (request.settled) return;
const response = raw as Record<string, unknown>;
const model = response['model'];
const modelSource = response['modelSource'];
if (
typeof model !== 'string' ||
(modelSource !== 'fast' && modelSource !== 'main')
) {
throw new Error('Malformed generation completion');
}
const accepted = queue.push({
type: 'done',
requestId,
model,
modelSource,
...(typeof response['inputTokens'] === 'number'
? { inputTokens: response['inputTokens'] }
: {}),
...(typeof response['outputTokens'] === 'number'
? { outputTokens: response['outputTokens'] }
: {}),
});
if (accepted) queue.close();
else queue.fail(new Error('Generation stream consumer too slow'));
})
.catch((error: unknown) => {
if (!request.settled) {
queue.fail(error);
if (purpose === 'btw' && error instanceof BridgeTimeoutError)
cancel();
}
})
.finally(() => {
request.settled = true;
signal.removeEventListener('abort', cancel);
generationRequests.delete(requestId);
endSideRequest();
});
return queue;
}
const bridgeApi: AcpSessionBridge = {
// Derived once from the frozen overrides and the configured channel
// factory; immutable for the bridge's lifetime.
@@ -14889,94 +14994,17 @@ export function createSessionControlPlane(
},
generateSessionContent(sessionId, prompt, signal, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const info = channelInfoForEntry(entry);
if (!info || info.harness.isDying)
throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
const endSideRequest = beginSideRequest(entry);
return startSessionGeneration(sessionId, prompt, signal, context);
},
const requestId = randomUUID();
const queue = new GenerationStreamQueue<BridgeGenerationStreamEvent>(
GENERATION_STREAM_QUEUE_CAPACITY,
);
const request = {
generateSessionBtwStream(sessionId, question, signal, context) {
return startSessionGeneration(
sessionId,
connection: entry.connection,
queue,
settled: false,
};
generationRequests.set(requestId, request);
const cancel = () => {
if (request.settled) return;
request.settled = true;
generationRequests.delete(requestId);
queue.close();
void entry.connection
.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGenerationCancel, {
sessionId,
requestId,
})
.catch(() => undefined);
};
signal.addEventListener('abort', cancel, { once: true });
if (signal.aborted) {
cancel();
endSideRequest();
return queue;
}
void Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
{ sessionId, requestId, prompt },
),
SESSION_GENERATION_TIMEOUT_MS,
SERVE_CONTROL_EXT_METHODS.sessionGenerationStart,
),
getTransportClosedReject(entry),
])
.then((raw) => {
if (request.settled) return;
const response = raw as Record<string, unknown>;
const model = response['model'];
const modelSource = response['modelSource'];
if (
typeof model !== 'string' ||
(modelSource !== 'fast' && modelSource !== 'main')
) {
throw new Error('Malformed generation completion');
}
const accepted = queue.push({
type: 'done',
requestId,
model,
modelSource,
...(typeof response['inputTokens'] === 'number'
? { inputTokens: response['inputTokens'] }
: {}),
...(typeof response['outputTokens'] === 'number'
? { outputTokens: response['outputTokens'] }
: {}),
});
if (accepted) queue.close();
else queue.fail(new Error('Generation stream consumer too slow'));
})
.catch((error: unknown) => {
if (!request.settled) queue.fail(error);
})
.finally(() => {
request.settled = true;
signal.removeEventListener('abort', cancel);
generationRequests.delete(requestId);
endSideRequest();
});
return queue;
question,
signal,
context,
'btw',
);
},
getPendingPrompts(sessionId, context) {
@@ -35,6 +35,7 @@ import {
} from '@qwen-code/qwen-code-core/services/session-execution-engine.js';
import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base';
import { getDefaultReasoningConfig } from './model-configuration.js';
import { BTW_STREAM_TIMEOUT_MS } from './btw-generation.js';
import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js';
const mockPrepareFileWatchersForProcessExit = vi.hoisted(() => vi.fn());
@@ -284,8 +285,19 @@ vi.mock('node:stream', async (importOriginal) => {
};
});
const { mockExecuteBtwGeneration } = vi.hoisted(() => ({
mockExecuteBtwGeneration: vi.fn(),
}));
vi.mock('./btw-generation.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./btw-generation.js')>()),
executeBtwGeneration: mockExecuteBtwGeneration,
}));
// Mock core dependencies
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
BTW_MAX_INPUT_LENGTH: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).BTW_MAX_INPUT_LENGTH,
getModelsForProviderProtocol: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).getModelsForProviderProtocol,
@@ -2542,6 +2554,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
mockRunManagedAutoMemoryDream.mockReset();
mockRunManagedRememberByAgent.mockReset();
mockExecuteGeneration.mockReset();
mockExecuteBtwGeneration.mockReset();
mcpServerRequiresOAuth.clear();
mockHistoryPendingToolCalls.mockReturnValue([]);
lastSessionMock = undefined;
@@ -8180,6 +8193,50 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('dispatches BTW generation through its context-aware helper and actual child deadline', async () => {
const sessionId = 'btw-positive-dispatch';
const innerConfig = await setupSessionMocks(sessionId);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const timeout = vi.spyOn(AbortSignal, 'timeout');
mockExecuteBtwGeneration.mockResolvedValueOnce({
model: 'main',
modelSource: 'main',
inputTokens: 3,
outputTokens: 2,
});
try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGenerationStart, {
sessionId,
requestId: 'btw-request',
prompt: 'Why?',
purpose: 'btw',
}),
).resolves.toEqual({
sessionId,
requestId: 'btw-request',
model: 'main',
modelSource: 'main',
inputTokens: 3,
outputTokens: 2,
});
expect(mockExecuteBtwGeneration).toHaveBeenCalledExactlyOnceWith(
innerConfig,
'Why?',
expect.any(AbortSignal),
expect.any(Function),
);
expect(mockExecuteGeneration).not.toHaveBeenCalled();
expect(timeout).toHaveBeenCalledWith(BTW_STREAM_TIMEOUT_MS);
expect(BTW_STREAM_TIMEOUT_MS).toBeGreaterThan(300_000);
} finally {
timeout.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('rejects SSH workspace relocation and local runtime mutations before dispatch', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);
@@ -8212,6 +8269,33 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
message: 'This operation is unavailable for SSH workspaces.',
errorKind: 'unsupported_operation',
});
mockExecuteBtwGeneration.mockResolvedValue({
model: 'main',
modelSource: 'main',
});
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGenerationStart, {
sessionId,
requestId: 'ssh-btw-request',
prompt: 'Why?',
purpose: 'btw',
}),
).rejects.toMatchObject({
message: 'This operation is unavailable for SSH workspaces.',
errorKind: 'unsupported_operation',
});
expect(mockExecuteBtwGeneration).not.toHaveBeenCalled();
mockExecuteGeneration.mockResolvedValueOnce({
model: 'generic',
modelSource: 'main',
});
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGenerationStart, {
sessionId,
requestId: 'ssh-generic-request',
prompt: 'Generate text',
}),
).resolves.toMatchObject({ model: 'generic', modelSource: 'main' });
expect(relocateWorkingDirectory).not.toHaveBeenCalled();
Object.assign(mockConfig, {
getExecutionEnvironment: vi.fn().mockReturnValue({}),
+38 -15
View File
@@ -543,6 +543,10 @@ import {
GENERATION_TIMEOUT_MS,
type GenerationEvent,
} from './generation.js';
import {
executeBtwGeneration,
BTW_STREAM_TIMEOUT_MS,
} from './btw-generation.js';
type SessionOwnedWorkflowTool = {
buildSessionOwnedBackground(
@@ -9920,10 +9924,15 @@ class QwenAgent implements Agent {
typeof sessionId === 'string'
? this.sessions.get(sessionId)?.getConfig()
: undefined;
const guardedMethod =
method === SERVE_CONTROL_EXT_METHODS.sessionGenerationStart &&
params['purpose'] === 'btw'
? SERVE_CONTROL_EXT_METHODS.sessionBtw
: method;
if (
(this.config.getExecutionEnvironment?.() ||
sessionConfig?.getExecutionEnvironment?.()) &&
!isSshWorkspaceExtMethodAllowed(method)
!isSshWorkspaceExtMethodAllowed(guardedMethod)
) {
throw RequestError.invalidParams(
{ errorKind: 'unsupported_operation' },
@@ -13306,12 +13315,15 @@ class QwenAgent implements Agent {
const sessionId = params['sessionId'];
const requestId = params['requestId'];
const prompt = params['prompt'];
const purpose = params['purpose'];
if (
typeof sessionId !== 'string' ||
typeof requestId !== 'string' ||
typeof prompt !== 'string' ||
prompt.trim().length === 0 ||
Buffer.byteLength(prompt, 'utf8') > GENERATION_MAX_PROMPT_BYTES
Buffer.byteLength(prompt, 'utf8') > GENERATION_MAX_PROMPT_BYTES ||
(purpose !== undefined && purpose !== 'btw') ||
(purpose === 'btw' && prompt.length > BTW_MAX_INPUT_LENGTH)
) {
throw RequestError.invalidParams(
undefined,
@@ -13330,21 +13342,32 @@ class QwenAgent implements Agent {
this.generationControllers.set(requestId, { sessionId, controller });
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(GENERATION_TIMEOUT_MS),
AbortSignal.timeout(
purpose === 'btw' ? BTW_STREAM_TIMEOUT_MS : GENERATION_TIMEOUT_MS,
),
]);
try {
const result = await executeGeneration(
session.getConfig(),
requestId,
prompt,
signal,
async (event) => {
await this.connection.extNotification(
'qwen/notify/session/generation/event',
{ v: 1, sessionId, requestId, event },
);
},
);
const emit = async (event: GenerationEvent) => {
await this.connection.extNotification(
'qwen/notify/session/generation/event',
{ v: 1, sessionId, requestId, event },
);
};
const result =
purpose === 'btw'
? await executeBtwGeneration(
session.getConfig(),
prompt,
signal,
emit,
)
: await executeGeneration(
session.getConfig(),
requestId,
prompt,
signal,
emit,
);
return { sessionId, requestId, ...result };
} finally {
this.generationControllers.delete(requestId);
@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import { buildBtwCacheSafeParams } from '@qwen-code/qwen-code-core/utils/btwUtils.js';
import type {
CachePathParams,
ForkedQueryResult,
} from '@qwen-code/qwen-code-core/agents/forkedAgent.js';
const { runForkedAgent } = vi.hoisted(() => ({
runForkedAgent:
vi.fn<(params: CachePathParams) => Promise<ForkedQueryResult>>(),
}));
import { executeBtwGeneration } from './btw-generation.js';
vi.mock('@qwen-code/qwen-code-core/utils/btwUtils.js', () => ({
buildBtwCacheSafeParams: vi.fn(),
buildBtwPrompt: (question: string) => `btw: ${question}`,
}));
vi.mock('@qwen-code/qwen-code-core/agents/forkedAgent.js', () => ({
runForkedAgent,
}));
const config = { getModel: () => 'main' } as unknown as Config;
beforeEach(() => vi.clearAllMocks());
describe('executeBtwGeneration', () => {
it('streams the history-aware tool-free fork and returns usage', async () => {
const cache = {
model: 'session-model',
generationConfig: {},
history: [],
version: 0,
};
vi.mocked(buildBtwCacheSafeParams).mockReturnValue(cache);
vi.mocked(runForkedAgent).mockImplementation(async (params) => {
if (!('cacheSafeParams' in params))
throw new Error('Expected cache-path fork');
await params.onText?.('Hello');
await params.onText?.(' world');
return {
text: 'Hello world',
model: 'resolved',
usage: { inputTokens: 3, outputTokens: 2, cacheHitTokens: 1 },
};
});
const emit = vi.fn().mockResolvedValue(undefined);
const signal = new AbortController().signal;
expect(await executeBtwGeneration(config, ' Why? ', signal, emit)).toEqual({
model: 'resolved',
modelSource: 'main',
inputTokens: 3,
outputTokens: 2,
});
expect(runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({
config,
cacheSafeParams: cache,
abortSignal: signal,
userMessage: 'btw: Why?',
}),
);
expect(emit.mock.calls.map(([event]) => event)).toEqual([
{ type: 'started', model: 'session-model', modelSource: 'main' },
{ type: 'delta', seq: 0, text: 'Hello' },
{ type: 'delta', seq: 1, text: ' world' },
]);
});
it('completes with no answer when no conversation context exists', async () => {
vi.mocked(buildBtwCacheSafeParams).mockReturnValue(null);
expect(
await executeBtwGeneration(
config,
'Why?',
new AbortController().signal,
vi.fn(),
),
).toEqual({ model: 'main', modelSource: 'main' });
expect(runForkedAgent).not.toHaveBeenCalled();
});
it('does not publish text or succeed after cancellation', async () => {
vi.mocked(buildBtwCacheSafeParams).mockReturnValue({
model: 'main',
generationConfig: {},
history: [],
version: 0,
});
const controller = new AbortController();
vi.mocked(runForkedAgent).mockImplementation(async (params) => {
controller.abort();
if ('cacheSafeParams' in params) await params.onText?.('late text');
throw new Error('Should not reach completion');
});
const emit = vi.fn();
await expect(
executeBtwGeneration(config, 'Why?', controller.signal, emit),
).rejects.toMatchObject({ name: 'AbortError' });
expect(emit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import {
buildBtwCacheSafeParams,
buildBtwPrompt,
} from '@qwen-code/qwen-code-core/utils/btwUtils.js';
import { runForkedAgent } from '@qwen-code/qwen-code-core/agents/forkedAgent.js';
import type { GenerationEvent, GenerationResult } from './generation.js';
export const BTW_STREAM_TIMEOUT_MS = 305_000;
export async function executeBtwGeneration(
config: Config,
question: string,
signal: AbortSignal,
emit: (event: GenerationEvent) => Promise<void>,
): Promise<GenerationResult> {
const cacheSafeParams = buildBtwCacheSafeParams(config);
const model = cacheSafeParams?.model ?? config.getModel() ?? '';
await emit({ type: 'started', model, modelSource: 'main' });
if (!cacheSafeParams) return { model, modelSource: 'main' };
let seq = 0;
const result = await runForkedAgent({
config,
userMessage: buildBtwPrompt(question.trim()),
cacheSafeParams,
abortSignal: signal,
onText: async (text) => {
signal.throwIfAborted();
await emit({ type: 'delta', seq: seq++, text });
},
});
signal.throwIfAborted();
return {
model: result.model,
modelSource: 'main',
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
};
}
+332
View File
@@ -0,0 +1,332 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GenerationStreamQueue } from '@qwen-code/acp-bridge';
import type { BridgeGenerationStreamEvent } from '@qwen-code/acp-bridge';
import type { AcpSessionBridge } from './acp-session-bridge.js';
import { BtwStreamError, BtwStreamRequests } from './btw-stream.js';
function fixture() {
const queues: Array<GenerationStreamQueue<BridgeGenerationStreamEvent>> = [];
let generationSignal: AbortSignal | undefined;
const generateSessionBtwStream = vi.fn(
(_session: string, _question: string, signal: AbortSignal) => {
generationSignal ??= signal;
const queue = new GenerationStreamQueue<BridgeGenerationStreamEvent>(8);
queues.push(queue);
signal.addEventListener('abort', () => queue.close(), { once: true });
return queue;
},
);
const bridge = { generateSessionBtwStream } as unknown as AcpSessionBridge;
const requests = new BtwStreamRequests();
const open = (resume = false, question = 'Why?') =>
requests.open(bridge, 'session', 'client', 'request', question, resume);
const push = (event: BridgeGenerationStreamEvent) => queues[0]!.push(event);
return {
requests,
bridge,
open,
push,
generateSessionBtwStream,
signal: () => generationSignal,
};
}
afterEach(() => vi.useRealTimers());
describe('BTW answer snapshots', () => {
it('reconnects to the latest answer without duplicating model calls or text', async () => {
const f = fixture();
const request = f.open();
const connection = new AbortController();
const stream = request.subscribe(connection.signal);
expect((await stream.next()).value).toMatchObject({
type: 'answer',
answer: '',
});
f.push({ type: 'delta', requestId: 'upstream', seq: 0, text: 'Hello' });
expect((await stream.next()).value).toMatchObject({ answer: 'Hello' });
connection.abort();
await stream.return(undefined);
expect(f.signal()?.aborted).toBe(false);
f.push({ type: 'delta', requestId: 'upstream', seq: 1, text: ' world' });
f.push({
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
});
const resumed = f.open(true).subscribe(new AbortController().signal);
const snapshots = [];
for await (const snapshot of resumed) snapshots.push(snapshot);
expect(snapshots.at(-1)).toEqual({
type: 'done',
requestId: 'request',
answer: 'Hello world',
});
expect(f.generateSessionBtwStream).toHaveBeenCalledTimes(1);
expect(f.open()).toBe(request);
});
it('rejects conflicts and missing resumes without starting model work', () => {
const f = fixture();
expect(() => f.open(true)).toThrow('expired');
expect(f.generateSessionBtwStream).not.toHaveBeenCalled();
f.open();
expect(() => f.open(true, 'Another question')).toThrow(
'different question',
);
expect(f.generateSessionBtwStream).toHaveBeenCalledTimes(1);
f.requests.cancel(f.bridge, 'session', 'client', 'request');
});
it('isolates bridge, session and client identity on resume', () => {
const f = fixture();
f.open();
for (const [bridge, session, client] of [
[{} as AcpSessionBridge, 'session', 'client'],
[f.bridge, 'other-session', 'client'],
[f.bridge, 'session', 'other-client'],
] as const) {
expect(() =>
f.requests.open(bridge, session, client, 'request', 'Why?', true),
).toThrow('expired');
}
f.requests.cancel(f.bridge, 'session', 'client', 'request');
});
it('cancels only the requested generation and remembers cancel-before-start', async () => {
const f = fixture();
const request = f.open();
f.requests.cancel(f.bridge, 'session', 'client', 'request');
expect(f.signal()?.aborted).toBe(true);
expect(
(await request.subscribe(new AbortController().signal).next()).value,
).toMatchObject({ type: 'error', code: 'btw_cancelled' });
f.requests.cancel(f.bridge, 'session', 'client', 'not-started');
const cancelled = f.requests.open(
f.bridge,
'session',
'client',
'not-started',
'Why?',
false,
);
expect(
(await cancelled.subscribe(new AbortController().signal).next()).value,
).toMatchObject({ code: 'btw_cancelled' });
expect(f.generateSessionBtwStream).toHaveBeenCalledTimes(1);
});
it('rejects a 33rd active request but always allows cancelling a known request', async () => {
const f = fixture();
const request = f.open();
for (let i = 1; i < 32; i++)
f.requests.open(
f.bridge,
'session',
'client',
`active-${i}`,
'Why?',
false,
);
for (const operation of [
() =>
f.requests.open(
f.bridge,
'session',
'client',
'overflow',
'Why?',
false,
),
() => f.requests.cancel(f.bridge, 'session', 'client', 'unknown'),
]) {
expect(operation).toThrow(BtwStreamError);
try {
operation();
} catch (error) {
expect(error).toMatchObject({ status: 429, code: 'btw_limit' });
}
}
expect(f.generateSessionBtwStream).toHaveBeenCalledTimes(32);
f.requests.cancel(f.bridge, 'session', 'client', 'request');
expect(f.signal()?.aborted).toBe(true);
expect(
(await request.subscribe(new AbortController().signal).next()).value,
).toMatchObject({ code: 'btw_cancelled' });
for (let i = 1; i < 32; i++) {
expect(f.requests.has(f.bridge, 'session', 'client', `active-${i}`)).toBe(
true,
);
f.requests.cancel(f.bridge, 'session', 'client', `active-${i}`);
}
});
it('evicts the oldest terminal receipt and clears its expiry before reusing the ID', async () => {
vi.useFakeTimers();
const f = fixture();
f.open();
for (let i = 1; i < 32; i++)
f.requests.open(
f.bridge,
'session',
'client',
`active-${i}`,
'Why?',
false,
);
f.requests.cancel(f.bridge, 'session', 'client', 'active-1');
f.push({
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
});
await vi.advanceTimersByTimeAsync(1_000);
f.requests.open(f.bridge, 'session', 'client', 'new', 'Why?', false);
expect(() => f.open(true)).toThrow('expired');
expect(f.requests.has(f.bridge, 'session', 'client', 'active-1')).toBe(
true,
);
f.open();
await vi.advanceTimersByTimeAsync(59_000);
expect(f.requests.has(f.bridge, 'session', 'client', 'request')).toBe(true);
expect(f.requests.has(f.bridge, 'session', 'client', 'active-1')).toBe(
false,
);
expect(f.generateSessionBtwStream).toHaveBeenCalledTimes(34);
for (const id of [
'request',
'new',
...Array.from({ length: 30 }, (_, i) => `active-${i + 2}`),
])
f.requests.cancel(f.bridge, 'session', 'client', id);
});
it('retains completion and cancellation until their individual 60-second boundary', async () => {
vi.useFakeTimers();
const f = fixture();
f.open();
f.push({
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
});
await vi.advanceTimersByTimeAsync(1_000);
f.requests.cancel(f.bridge, 'session', 'client', 'cancelled');
await vi.advanceTimersByTimeAsync(58_999);
expect(f.requests.has(f.bridge, 'session', 'client', 'request')).toBe(true);
await vi.advanceTimersByTimeAsync(1);
expect(() => f.open(true)).toThrow('expired');
expect(f.requests.has(f.bridge, 'session', 'client', 'cancelled')).toBe(
true,
);
await vi.advanceTimersByTimeAsync(999);
expect(f.requests.has(f.bridge, 'session', 'client', 'cancelled')).toBe(
true,
);
await vi.advanceTimersByTimeAsync(1);
expect(f.requests.has(f.bridge, 'session', 'client', 'cancelled')).toBe(
false,
);
});
it('owns the five-minute timeout before the child or bridge deadline', async () => {
vi.useFakeTimers();
const f = fixture();
const request = f.open();
await vi.advanceTimersByTimeAsync(299_999);
expect(f.signal()?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(f.signal()?.aborted).toBe(true);
expect(
(await request.subscribe(new AbortController().signal).next()).value,
).toMatchObject({ type: 'error', code: 'btw_timeout' });
});
it.each([1024 * 1024, 1024 * 1024 + 1])(
'bounds an answer of %i UTF-8 bytes',
async (bytes) => {
const f = fixture();
const request = f.open();
f.push({
type: 'delta',
requestId: 'upstream',
seq: 0,
text: 'x'.repeat(bytes),
});
f.push({
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
});
const snapshots = [];
for await (const snapshot of request.subscribe(
new AbortController().signal,
))
snapshots.push(snapshot);
if (bytes === 1024 * 1024) {
expect(snapshots.at(-1)).toMatchObject({
type: 'done',
answer: 'x'.repeat(bytes),
});
expect(f.signal()?.aborted).toBe(false);
} else {
expect(snapshots.at(-1)).toMatchObject({
type: 'error',
code: 'btw_failed',
});
expect(f.signal()?.aborted).toBe(true);
}
},
);
it('preserves whitespace in partial snapshots and trims the final answer like legacy BTW', async () => {
const f = fixture();
const stream = f.open().subscribe(new AbortController().signal);
await stream.next();
f.push({
type: 'delta',
requestId: 'upstream',
seq: 0,
text: ' answer \n',
});
expect((await stream.next()).value).toMatchObject({
type: 'answer',
answer: ' answer \n',
});
f.push({
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
});
expect((await stream.next()).value).toMatchObject({
type: 'done',
answer: 'answer',
});
await stream.next();
});
it('reports generation failure as a retained terminal snapshot', async () => {
const f = fixture();
f.generateSessionBtwStream.mockImplementation(() => {
throw new Error('upstream failed');
});
const request = f.open();
expect(
(await request.subscribe(new AbortController().signal).next()).value,
).toMatchObject({ type: 'error', code: 'btw_failed' });
expect(f.open(true)).toBe(request);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpSessionBridge } from './acp-session-bridge.js';
import type { Response } from 'express';
import { ArtifactLibraryStream } from './server/artifact-library-stream.js';
const RETENTION_MS = 60_000;
const MAX_REQUESTS = 32;
const MAX_ANSWER_BYTES = 1024 * 1024;
type BtwSnapshot =
| { type: 'answer' | 'done'; requestId: string; answer: string }
| { type: 'error'; requestId: string; code: string; message: string };
export class BtwStreamError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
class BtwStreamRequest {
private readonly abort = new AbortController();
private readonly listeners = new Set<() => void>();
private revision = 0;
private snapshot: BtwSnapshot;
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(
readonly question: string | undefined,
requestId: string,
private readonly expire: () => void,
) {
this.snapshot = { type: 'answer', requestId, answer: '' };
}
private update(snapshot: BtwSnapshot): void {
if (this.snapshot.type !== 'answer') return;
this.snapshot = snapshot;
this.revision++;
if (snapshot.type !== 'answer') {
clearTimeout(this.timer);
this.timer = setTimeout(this.expire, RETENTION_MS);
this.timer.unref();
}
for (const wake of this.listeners) wake();
}
get terminal(): boolean {
return this.snapshot.type !== 'answer';
}
dispose(): void {
clearTimeout(this.timer);
}
cancel(): void {
this.update({
type: 'error',
requestId: this.snapshot.requestId,
code: 'btw_cancelled',
message: 'Side question cancelled',
});
this.abort.abort();
}
async run(bridge: AcpSessionBridge, sessionId: string, clientId?: string) {
this.timer = setTimeout(() => {
this.update({
type: 'error',
requestId: this.snapshot.requestId,
code: 'btw_timeout',
message: 'Side question timed out',
});
this.abort.abort();
}, 300_000);
this.timer.unref();
let answer = '';
let bytes = 0;
try {
for await (const event of bridge.generateSessionBtwStream!(
sessionId,
this.question!,
this.abort.signal,
clientId !== undefined ? { clientId } : undefined,
)) {
if (this.abort.signal.aborted) return;
if (event.type === 'delta') {
bytes += Buffer.byteLength(event.text, 'utf8');
if (bytes > MAX_ANSWER_BYTES) {
throw new Error('Side question answer exceeds 1 MiB');
}
answer += event.text;
this.update({
type: 'answer',
requestId: this.snapshot.requestId,
answer,
});
} else if (event.type === 'done') {
this.update({
type: 'done',
requestId: this.snapshot.requestId,
answer: answer.trim(),
});
return;
}
}
throw new Error('Side question stream ended before completion');
} catch {
this.update({
type: 'error',
requestId: this.snapshot.requestId,
code: 'btw_failed',
message: 'Side question failed',
});
this.abort.abort();
}
}
async *subscribe(signal: AbortSignal): AsyncGenerator<BtwSnapshot> {
let revision = -1;
while (!signal.aborted) {
if (revision !== this.revision) {
revision = this.revision;
const snapshot = this.snapshot;
yield snapshot;
if (snapshot.type !== 'answer') return;
}
if (signal.aborted) return;
await new Promise<void>((resolve) => {
const wake = () => {
this.listeners.delete(wake);
signal.removeEventListener('abort', wake);
resolve();
};
this.listeners.add(wake);
signal.addEventListener('abort', wake, { once: true });
if (signal.aborted || revision !== this.revision) wake();
});
}
}
}
export class BtwStreamRequests {
private readonly runtimes = new WeakMap<
AcpSessionBridge,
Map<string, BtwStreamRequest>
>();
private requests(bridge: AcpSessionBridge): Map<string, BtwStreamRequest> {
let requests = this.runtimes.get(bridge);
if (!requests) {
requests = new Map();
this.runtimes.set(bridge, requests);
}
return requests;
}
private key(
sessionId: string,
clientId: string | undefined,
requestId: string,
) {
return JSON.stringify([sessionId, clientId ?? null, requestId]);
}
has(
bridge: AcpSessionBridge,
sessionId: string,
clientId: string | undefined,
requestId: string,
): boolean {
return (
this.runtimes
.get(bridge)
?.has(this.key(sessionId, clientId, requestId)) ?? false
);
}
open(
bridge: AcpSessionBridge,
sessionId: string,
clientId: string | undefined,
requestId: string,
question: string,
resume: boolean,
): BtwStreamRequest {
const requests = this.requests(bridge);
const key = this.key(sessionId, clientId, requestId);
const existing = requests.get(key);
if (existing) {
if (existing.question !== undefined && existing.question !== question) {
throw new BtwStreamError(
409,
'btw_request_conflict',
'This request ID belongs to a different question',
);
}
return existing;
}
if (resume) {
throw new BtwStreamError(410, 'btw_expired', 'Side question has expired');
}
const request = this.create(requests, key, question, requestId);
void request.run(bridge, sessionId, clientId);
return request;
}
cancel(
bridge: AcpSessionBridge,
sessionId: string,
clientId: string | undefined,
requestId: string,
): void {
const requests = this.requests(bridge);
const key = this.key(sessionId, clientId, requestId);
const request =
requests.get(key) ?? this.create(requests, key, undefined, requestId);
request.cancel();
}
private create(
requests: Map<string, BtwStreamRequest>,
key: string,
question: string | undefined,
requestId: string,
): BtwStreamRequest {
if (requests.size >= MAX_REQUESTS) {
const oldestTerminal = [...requests].find(
([, request]) => request.terminal,
);
if (!oldestTerminal) {
throw new BtwStreamError(429, 'btw_limit', 'Too many side questions');
}
oldestTerminal[1].dispose();
requests.delete(oldestTerminal[0]);
}
const request = new BtwStreamRequest(question, requestId, () => {
requests.delete(key);
});
requests.set(key, request);
return request;
}
}
export async function streamBtwSnapshots(
res: Response,
request: BtwStreamRequest,
): Promise<void> {
const connection = new AbortController();
const onClose = () => connection.abort();
res.once('close', onClose);
const stream = new ArtifactLibraryStream(res);
try {
for await (const snapshot of request.subscribe(connection.signal)) {
await stream.send(snapshot.type, snapshot);
}
} catch {
// A failed connection can reattach to the same generation.
} finally {
connection.abort();
res.off('close', onClose);
stream.end();
}
}
+6
View File
@@ -372,6 +372,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// Side question (/btw) against the session's conversation context.
// Single-turn, tool-free LLM call via runForkedAgent (cache path).
session_btw: { since: 'v1' },
session_btw_stream: { since: 'v1' },
// Direct daemon-side shell execution for an existing session.
// Advertised CONDITIONALLY: operators must explicitly enable it and
// either configure bearer auth or use trusted-loopback mode. Clients must
@@ -656,6 +657,7 @@ export interface AdvertiseFeatureToggles {
sessionShellCommandEnabled?: boolean;
sessionArtifactsPersistenceAvailable?: boolean;
sessionGenerationAvailable?: boolean;
sessionBtwStreamAvailable?: boolean;
currentSessionSchedulingAvailable?: boolean;
workspaceGenerationAvailable?: boolean;
rateLimit?: boolean;
@@ -815,6 +817,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
'session_generation',
(toggles) => toggles.sessionGenerationAvailable === true,
],
[
'session_btw_stream',
(toggles) => toggles.sessionBtwStreamAvailable === true,
],
[
'scheduled_task_session_reuse',
(toggles) => toggles.currentSessionSchedulingAvailable === true,
@@ -389,6 +389,9 @@ export const CAPABILITY_PARITY: Record<ServeFeature, ParityRow> = {
'no Managed stateless generation route',
),
session_btw: NO_MANAGED_EQUIVALENT('no Managed side-question route'),
session_btw_stream: NO_MANAGED_EQUIVALENT(
'no Managed streaming side-question route',
),
session_shell_command: {
standing: 'managed',
evidence: 'tool:run_shell_command',
@@ -4544,6 +4544,179 @@ describe('multi-workspace session dispatch', () => {
expect(secondaryBridge.sendMidTurnNowCalls).toEqual([]);
});
it('advertises streaming BTW only when every runtime bridge supports it', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
});
const generate = async function* () {
yield {
type: 'done' as const,
requestId: 'request',
model: 'main',
modelSource: 'main' as const,
};
};
primaryBridge.generateSessionBtwStream = generate;
const capabilities = () =>
request(app)
.get('/capabilities')
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION);
expect((await capabilities()).body.features).not.toContain(
'session_btw_stream',
);
secondaryBridge.generateSessionBtwStream = generate;
expect((await capabilities()).body.features).toContain(
'session_btw_stream',
);
delete primaryBridge.generateSessionBtwStream;
expect((await capabilities()).body.features).not.toContain(
'session_btw_stream',
);
});
it('keeps streaming BTW starts, resumes and cancellation in the session owner', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
});
const primary = vi.fn(async function* () {
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'primary',
modelSource: 'main' as const,
};
});
const secondary = vi.fn(async function* () {
yield {
type: 'delta' as const,
requestId: 'upstream',
seq: 0,
text: 'secondary answer',
};
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'secondary',
modelSource: 'main' as const,
};
});
primaryBridge.generateSessionBtwStream = primary;
secondaryBridge.generateSessionBtwStream = secondary;
const sessionId = '22222222-2222-4222-a222-222222222222';
for (const resume of [false, true]) {
const response = await request(app)
.post(`/session/${sessionId}/btw/stream`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'secondary-client')
.send({ question: 'Why?', requestId: 'same-request', resume });
expect(response.status).toBe(200);
expect(response.text).toContain('secondary answer');
}
expect(secondary).toHaveBeenCalledTimes(1);
expect(secondary).toHaveBeenCalledWith(
sessionId,
'Why?',
expect.any(AbortSignal),
{ clientId: 'secondary-client' },
);
const cancel = await request(app)
.delete(`/session/${sessionId}/btw/stream/same-request`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'secondary-client');
expect(cancel.status).toBe(204);
const foreign = await request(app)
.post(`/session/${sessionId}/btw/stream`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'different-client')
.send({ question: 'Why?', requestId: 'same-request', resume: true });
expect(foreign.status).toBe(410);
const missing = await request(app)
.post('/session/33333333-3333-4333-a333-333333333333/btw/stream')
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.send({ question: 'Why?', requestId: 'same-request', resume: true });
expect(missing.status).toBe(404);
expect(primary).not.toHaveBeenCalled();
});
it('cancels an active streaming BTW only in the secondary owner', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
});
let ready!: () => void;
const admitted = new Promise<void>((resolve) => {
ready = resolve;
});
let signal: AbortSignal | undefined;
const primary = vi.fn();
primaryBridge.generateSessionBtwStream = primary;
secondaryBridge.generateSessionBtwStream = vi.fn(
async function* (_session, _question, generationSignal) {
signal = generationSignal;
ready();
await new Promise<void>((resolve) =>
generationSignal.addEventListener('abort', () => resolve(), {
once: true,
}),
);
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'secondary',
modelSource: 'main' as const,
};
},
);
const base = '/session/22222222-2222-4222-a222-222222222222/btw/stream';
const generation = request(app)
.post(base)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'secondary-client')
.send({ question: 'Why?', requestId: 'active' })
.then((response) => response);
await admitted;
const cancelled = await request(app)
.delete(`${base}/active`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'secondary-client');
expect(cancelled.status).toBe(204);
expect(signal?.aborted).toBe(true);
expect((await generation).text).toContain('btw_cancelled');
expect(primary).not.toHaveBeenCalled();
});
it('rejects streaming BTW for an untrusted owner without primary fallback', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
secondaryTrusted: false,
token: TEST_TOKEN,
});
const primary = vi.fn();
const secondary = vi.fn();
primaryBridge.generateSessionBtwStream = primary;
secondaryBridge.generateSessionBtwStream = secondary;
const base = '/session/22222222-2222-4222-a222-222222222222/btw/stream';
const responses = await Promise.all([
request(app)
.post(base)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.send({ question: 'Why?', requestId: 'request' }),
request(app)
.delete(`${base}/request`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION),
]);
expect(responses.map((response) => response.status)).toEqual([403, 403]);
expect(primary).not.toHaveBeenCalled();
expect(secondary).not.toHaveBeenCalled();
});
it('rejects invalid secondary owner-local inputs before bridge actions', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
+85
View File
@@ -126,6 +126,11 @@ import {
type BridgeSessionCatalogVersion,
} from '../acp-session-bridge.js';
import type { DaemonLogger } from '../daemon-logger.js';
import {
BtwStreamError,
BtwStreamRequests,
streamBtwSnapshots,
} from '../btw-stream.js';
import type { SendBridgeError } from '../server/error-response.js';
import {
WorktreeMarkerMissingError,
@@ -856,6 +861,7 @@ export function registerSessionRoutes(
app: Application,
deps: RegisterSessionRoutesDeps,
): { resolveSubagentSessionRuntime: ResolveSubagentSessionRuntime } {
const btwStreams = new BtwStreamRequests();
const {
boundWorkspace,
workspaceRegistry,
@@ -10387,6 +10393,85 @@ export function registerSessionRoutes(
),
);
const btwRequestId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
const btwStreamInput = z.object({
question: z.string().max(BTW_MAX_INPUT_LENGTH).trim().min(1),
requestId: btwRequestId,
resume: z.boolean().optional(),
});
const btwStreamHandler = (method: 'post' | 'delete') =>
withOwnerMutableSession(
method === 'post'
? 'POST /session/:id/btw/stream'
: 'DELETE /session/:id/btw/stream/:requestId',
(req, res, sessionId, runtime) => {
const parsed =
method === 'post'
? btwStreamInput.safeParse(safeBody(req))
: btwRequestId.safeParse(req.params['requestId']);
if (!parsed.success) {
res.status(400).json({
code: 'invalid_btw_request',
error:
method === 'post'
? 'A valid requestId and non-empty question are required'
: 'A valid requestId is required',
});
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const bridge = runtime.bridge;
// Session detach can win the race with cancelling its own cached request.
if (
typeof parsed.data !== 'string' ||
!btwStreams.has(bridge, sessionId, clientId, parsed.data)
) {
// Authorize this live-session client without starting model work.
bridge.getPendingPrompts(
sessionId,
clientId !== undefined ? { clientId } : undefined,
);
}
try {
if (typeof parsed.data === 'string') {
btwStreams.cancel(bridge, sessionId, clientId, parsed.data);
res.status(204).end();
return;
}
if (!bridge.generateSessionBtwStream) {
res.status(501).json({
code: 'btw_stream_not_supported',
error: 'Streaming side questions are not supported',
});
return;
}
const { requestId, question, resume } = parsed.data;
const request = btwStreams.open(
bridge,
sessionId,
clientId,
requestId,
question,
resume === true,
);
// The bridge's side request holds generation alive independently of HTTP.
void streamBtwSnapshots(res, request);
} catch (error) {
if (!(error instanceof BtwStreamError)) throw error;
res
.status(error.status)
.json({ code: error.code, error: error.message });
}
},
);
app.post('/session/:id/btw/stream', mutate(), btwStreamHandler('post'));
app.delete(
'/session/:id/btw/stream/:requestId',
mutate(),
btwStreamHandler('delete'),
);
app.post(
'/session/:id/btw',
mutate(),
+1
View File
@@ -2465,6 +2465,7 @@ function currentServeFeaturesForRunQwenServe(
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
sessionGenerationAvailable: true,
sessionBtwStreamAvailable: true,
currentSessionSchedulingAvailable,
workspaceGenerationAvailable: true,
rateLimit: opts.rateLimit === true,
+389
View File
@@ -864,6 +864,7 @@ const EXPECTED_REGISTERED_FEATURES = [
f !== 'session_generation' &&
f !== 'workspace_generation' &&
f !== 'session_btw' &&
f !== 'session_btw_stream' &&
f !== 'auth_device_flow' &&
f !== 'permission_mediation' &&
f !== 'non_blocking_prompt' &&
@@ -912,6 +913,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'session_generation',
'workspace_generation',
'session_btw',
'session_btw_stream',
'session_shell_command',
'mcp_workspace_pool',
'mcp_pool_restart',
@@ -3775,6 +3777,20 @@ describe('createServeApp', () => {
);
continue;
}
if (feature === 'session_btw_stream') {
expect(predicate({ sessionBtwStreamAvailable: true })).toBe(true);
expect(predicate({ sessionBtwStreamAvailable: false })).toBe(false);
expect(predicate({})).toBe(false);
expect(
getAdvertisedServeFeatures(undefined, {
sessionBtwStreamAvailable: true,
}),
).toContain(feature);
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
feature,
);
continue;
}
if (feature === 'scheduled_task_session_reuse') {
expect(predicate({ currentSessionSchedulingAvailable: true })).toBe(
true,
@@ -5926,6 +5942,33 @@ describe('createServeApp', () => {
expect(unsupported.body.features).not.toContain('session_generation');
});
it('advertises streaming BTW only when the bridge supports it', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const capabilities = () =>
request(app)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect((await capabilities()).body.features).not.toContain(
'session_btw_stream',
);
bridge.generateSessionBtwStream = async function* () {
yield {
type: 'done',
requestId: 'request',
model: 'main',
modelSource: 'main',
};
};
expect((await capabilities()).body.features).toContain(
'session_btw_stream',
);
delete bridge.generateSessionBtwStream;
const legacy = await capabilities();
expect(legacy.body.features).not.toContain('session_btw_stream');
expect(legacy.body.features).toContain('session_btw');
});
it('advertises current-session scheduling only with managed task sessions', async () => {
const unmanaged = await request(
createServeApp(baseOpts, undefined, {
@@ -6014,6 +6057,7 @@ describe('createServeApp', () => {
mcpPoolActive: true,
sessionArtifactsPersistenceAvailable: true,
sessionGenerationAvailable: true,
sessionBtwStreamAvailable: true,
workspaceGenerationAvailable: true,
workspaceRuntimeAvailable: true,
acpHttpEnabled: true,
@@ -27410,6 +27454,351 @@ describe('createServeApp', () => {
});
});
describe('streaming BTW routes', () => {
function streamingBridge() {
const bridge = fakeBridge();
bridge.generateSessionBtwStream = vi.fn(async function* () {
yield {
type: 'delta' as const,
requestId: 'upstream',
seq: 0,
text: 'partial',
};
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'main',
modelSource: 'main' as const,
};
});
return bridge;
}
it('streams answers before completion, resumes pending work, and releases close admission', async () => {
let finish!: () => void;
const completion = new Promise<void>((resolve) => {
finish = resolve;
});
let completed = false;
const bridge = streamingBridge();
bridge.generateSessionBtwStream = vi.fn(async function* () {
yield {
type: 'delta' as const,
requestId: 'upstream',
seq: 0,
text: 'partial',
};
await completion;
completed = true;
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'main',
modelSource: 'main' as const,
};
});
let port = baseOpts.port;
const app = createServeApp(
{ ...baseOpts, token: 'btw-test-token' },
() => port,
{ bridge },
);
const server = createServer(app);
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
port = (server.address() as AddressInfo).port;
const url = `http://127.0.0.1:${port}/session/session-A/btw/stream`;
const controller = new AbortController();
const submit = (resume: boolean) =>
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Authorization: 'Bearer btw-test-token',
'X-Qwen-Client-Id': 'client-1',
},
body: JSON.stringify({
question: ' Why? ',
requestId: 'request-1',
resume,
}),
signal: controller.signal,
});
const readThrough = async (
reader: ReadableStreamDefaultReader<Uint8Array>,
marker: string,
text = '',
) => {
const decoder = new TextDecoder();
while (!text.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) throw new Error(`SSE ended before ${marker}`);
text += decoder.decode(chunk.value, { stream: true });
}
return text;
};
try {
const response = await submit(false);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain(
'text/event-stream',
);
const reader = response.body!.getReader();
const partial = await readThrough(reader, '"answer":"partial"');
expect(partial).toContain('event: answer');
expect(partial).not.toContain('event: done');
expect(completed).toBe(false);
const resumed = await submit(true);
const resumedReader = resumed.body!.getReader();
const resumedPartial = await readThrough(
resumedReader,
'"answer":"partial"',
);
expect(resumedPartial).toContain('event: answer');
expect(resumedPartial).not.toContain('event: done');
expect(completed).toBe(false);
expect(bridge.generateSessionBtwStream).toHaveBeenCalledExactlyOnceWith(
'session-A',
'Why?',
expect.any(AbortSignal),
{ clientId: 'client-1' },
);
const close = await request(app)
.delete('/session/session-A')
.set('Host', `127.0.0.1:${port}`)
.set('Authorization', 'Bearer btw-test-token');
expect(close.status).toBe(204);
expect(bridge.closeCalls).toHaveLength(1);
expect(completed).toBe(false);
finish();
for (const [bodyReader, prefix] of [
[reader, partial],
[resumedReader, resumedPartial],
] as const) {
const text = await readThrough(bodyReader, 'event: done\n', prefix);
expect(text.indexOf('event: answer')).toBeLessThan(
text.indexOf('event: done'),
);
}
const cached = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${port}`)
.set('Authorization', 'Bearer btw-test-token')
.set('X-Qwen-Client-Id', 'client-1')
.send({ question: 'Why?', requestId: 'request-1', resume: true });
expect(cached.text).toContain('event: done');
expect(bridge.generateSessionBtwStream).toHaveBeenCalledTimes(1);
const conflict = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${port}`)
.set('Authorization', 'Bearer btw-test-token')
.set('X-Qwen-Client-Id', 'client-1')
.send({ question: 'Different?', requestId: 'request-1' });
expect(conflict.status).toBe(409);
expect(conflict.body.code).toBe('btw_request_conflict');
expect(bridge.generateSessionBtwCalls).toEqual([]);
} finally {
finish();
controller.abort();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it.each([
{ question: '', requestId: 'valid' },
{ question: 'x'.repeat(BTW_MAX_INPUT_LENGTH + 1), requestId: 'valid' },
{ question: 'Why?', requestId: '../invalid' },
{ question: 'Why?', requestId: 'valid', resume: 'yes' },
])('rejects invalid streaming BTW input before dispatch', async (body) => {
const bridge = streamingBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const response = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send(body);
expect(response.status).toBe(400);
expect(bridge.generateSessionBtwStream).not.toHaveBeenCalled();
});
it('returns 410 for an unknown resume, and 501 for unsupported streaming', async () => {
const bridge = streamingBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const response = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ question: 'Why?', requestId: 'unknown', resume: true });
expect(response.status).toBe(410);
expect(response.body.code).toBe('btw_expired');
expect(bridge.generateSessionBtwStream).not.toHaveBeenCalled();
delete bridge.generateSessionBtwStream;
const unsupported = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ question: 'Why?', requestId: 'unknown' });
expect(unsupported.status).toBe(501);
expect(unsupported.body.code).toBe('btw_stream_not_supported');
});
it('rejects invalid streaming cancellation IDs without requiring a question', async () => {
const bridge = streamingBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const response = await request(app)
.delete('/session/session-A/btw/stream/bad%20id')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(response.status).toBe(400);
expect(response.body).toEqual({
code: 'invalid_btw_request',
error: 'A valid requestId is required',
});
expect(bridge.generateSessionBtwStream).not.toHaveBeenCalled();
});
it('remembers cancellation that arrives before the initial POST', async () => {
const bridge = streamingBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const cancelled = await request(app)
.delete('/session/session-A/btw/stream/cancel-first')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(cancelled.status).toBe(204);
const result = await request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ question: 'Why?', requestId: 'cancel-first' });
expect(result.text).toContain('btw_cancelled');
expect(bridge.generateSessionBtwStream).not.toHaveBeenCalled();
});
it('cancels a cached request after its client detached, but rejects unknown cancels', async () => {
let attached = true;
let signal: AbortSignal | undefined;
let started: (() => void) | undefined;
const admitted = new Promise<void>((resolve) => {
started = resolve;
});
const bridge = fakeBridge({
getPendingPromptsImpl: () => {
if (!attached)
throw new InvalidClientIdError('session-A', 'client-1');
return [];
},
});
bridge.generateSessionBtwStream = async function* (
_session,
_question,
generationSignal,
) {
signal = generationSignal;
started?.();
await new Promise<void>((resolve) =>
generationSignal.addEventListener('abort', () => resolve(), {
once: true,
}),
);
yield {
type: 'done',
requestId: 'upstream',
model: 'main',
modelSource: 'main',
};
};
const app = createServeApp(baseOpts, undefined, { bridge });
const generation = request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'client-1')
.send({ question: 'Why?', requestId: 'known' })
.then((response) => response);
await admitted;
attached = false;
const cancel = (id: string) =>
request(app)
.delete(`/session/session-A/btw/stream/${id}`)
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'client-1');
expect((await cancel('known')).status).toBe(204);
expect(signal?.aborted).toBe(true);
expect((await generation).text).toContain('btw_cancelled');
const unknown = await cancel('unknown');
expect(unknown.status).toBe(400);
expect(unknown.body.code).toBe('invalid_client_id');
});
it('returns 429 when all 32 requests are active and still cancels a known request', async () => {
const signals = new Map<string, AbortSignal>();
const finishers: Array<() => void> = [];
let ready!: () => void;
const admitted = new Promise<void>((resolve) => {
ready = resolve;
});
const bridge = fakeBridge();
bridge.generateSessionBtwStream = vi.fn(
async function* (_session, question, signal) {
signals.set(question, signal);
if (signals.size === 32) ready();
await new Promise<void>((resolve) => {
finishers.push(resolve);
signal.addEventListener('abort', () => resolve(), { once: true });
});
yield {
type: 'done' as const,
requestId: 'upstream',
model: 'main',
modelSource: 'main' as const,
};
},
);
const app = createServeApp(baseOpts, undefined, { bridge });
const submit = (id: string) =>
request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ question: id, requestId: id });
const generations = Array.from({ length: 32 }, (_, i) =>
submit(`active-${i}`).then((response) => response),
);
try {
await admitted;
const rejected = await submit('overflow');
expect(rejected.status).toBe(429);
expect(rejected.body.code).toBe('btw_limit');
const cancel = await request(app)
.delete('/session/session-A/btw/stream/active-0')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(cancel.status).toBe(204);
expect(signals.get('active-0')?.aborted).toBe(true);
expect(
[...signals]
.filter(([id]) => id !== 'active-0')
.every(([, signal]) => !signal.aborted),
).toBe(true);
} finally {
for (const finish of finishers) finish();
await Promise.all(generations);
}
});
it('rejects malformed client IDs for starts and cancellations', async () => {
const bridge = streamingBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
for (const response of await Promise.all([
request(app)
.post('/session/session-A/btw/stream')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'bad client')
.send({ question: 'Why?', requestId: 'request' }),
request(app)
.delete('/session/session-A/btw/stream/request')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'bad client'),
]))
expect(response.status).toBe(400);
expect(bridge.generateSessionBtwStream).not.toHaveBeenCalled();
});
});
describe('POST /session/:id/btw', () => {
it('trims the question and forwards client identity plus an abort signal', async () => {
const bridge = fakeBridge();
+9
View File
@@ -1184,6 +1184,15 @@ export function createServeApp(
)
);
},
sessionBtwStreamAvailable: () => {
const runtimes = workspaceRegistry.listAll();
return (
runtimes.length > 0 &&
runtimes.every(
(runtime) => runtime.bridge.generateSessionBtwStream !== undefined,
)
);
},
currentSessionSchedulingAvailable:
deps.manageScheduledTaskSessions === true &&
deps.currentSessionSchedulingAvailable === true,
@@ -44,6 +44,7 @@ interface CreateServeFeaturesDeps {
persistSettingAvailable: boolean;
sessionArtifactsPersistenceAvailable: boolean;
sessionGenerationAvailable: () => boolean;
sessionBtwStreamAvailable: () => boolean;
currentSessionSchedulingAvailable: boolean;
workspaceGenerationAvailable: () => boolean;
reloadAvailable: boolean;
@@ -89,6 +90,7 @@ export function createServeFeatures(
persistSettingAvailable,
sessionArtifactsPersistenceAvailable,
sessionGenerationAvailable,
sessionBtwStreamAvailable,
currentSessionSchedulingAvailable,
workspaceGenerationAvailable,
reloadAvailable,
@@ -154,6 +156,7 @@ export function createServeFeatures(
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
sessionGenerationAvailable: sessionGenerationAvailable(),
sessionBtwStreamAvailable: sessionBtwStreamAvailable(),
currentSessionSchedulingAvailable,
workspaceGenerationAvailable: workspaceGenerationAvailable(),
rateLimit: opts.rateLimit === true,
@@ -104,7 +104,7 @@ describe('legacy session telemetry route drift guard', () => {
.map(({ method, path }) => `${method} ${path}`)
.sort();
expect(registered).toHaveLength(83);
expect(registered).toHaveLength(85);
expect(registered).toEqual(catalog);
});
});
@@ -1175,17 +1175,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
describe('legacy session telemetry route catalog', () => {
// Shell runtime and message group routes resolve the live session owner
// in the handler, preserving the two pre-resolved routes.
it('contains 83 unique routes with the audited 81/2 attribution split', () => {
it('contains 85 unique routes with the audited 83/2 attribution split', () => {
const keys = legacySessionTelemetryRoutes.map(
({ method, path }) => `${method} ${path}`,
);
expect(keys).toHaveLength(83);
expect(new Set(keys).size).toBe(83);
expect(keys).toHaveLength(85);
expect(new Set(keys).size).toBe(85);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'handler_resolved',
),
).toHaveLength(81);
).toHaveLength(83);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'pre_resolved',
@@ -1219,7 +1219,7 @@ describe('legacy session telemetry route catalog', () => {
route: entry.route,
attribution: entry.attribution,
...(entry.path.includes('/:id') ? { sessionId: 'session-1' } : {}),
...(entry.path.includes('/:requestId')
...(entry.path.endsWith('/permission/:requestId')
? { permissionRequestId: 'request-1' }
: {}),
});
@@ -1257,6 +1257,18 @@ describe('legacy session telemetry route catalog', () => {
},
);
it('does not label a BTW cancellation request as a permission request', () => {
const route = resolveDaemonTelemetryRoute(
mockReq('DELETE', '/session/session-1/btw/stream/btw-123'),
);
expect(route).toEqual({
route: 'DELETE /session/:id/btw/stream/:requestId',
attribution: 'handler_resolved',
sessionId: 'session-1',
});
expect(route).not.toHaveProperty('permissionRequestId');
});
it('decodes and validates permission request ids after segment matching', () => {
expect(
resolveDaemonTelemetryRoute(
+14 -1
View File
@@ -427,6 +427,18 @@ export const legacySessionTelemetryRoutes = [
attribution: 'handler_resolved',
route: 'POST /session/:id/btw',
},
{
method: 'POST',
path: '/session/:id/btw/stream',
attribution: 'handler_resolved',
route: 'POST /session/:id/btw/stream',
},
{
method: 'DELETE',
path: '/session/:id/btw/stream/:requestId',
attribution: 'handler_resolved',
route: 'DELETE /session/:id/btw/stream/:requestId',
},
{
method: 'POST',
path: '/session/:id/mid-turn-message',
@@ -608,7 +620,8 @@ function matchLegacySessionTelemetryRoute(
route: entry.route,
attribution: entry.attribution,
...(rawSessionId ? { sessionId: decodePathSegment(rawSessionId) } : {}),
...(requestId !== undefined &&
...(entry.path.endsWith('/permission/:requestId') &&
requestId !== undefined &&
requestId.length <= MAX_CLIENT_ID_LENGTH &&
CLIENT_ID_RE.test(requestId)
? { permissionRequestId: requestId }
@@ -391,6 +391,63 @@ describe('runForkedAgent (cache path)', () => {
expect(result.model).toBe('test-model');
});
it('streams only answer text while preserving the accumulated result', async () => {
saveCacheSafeParams({}, [], 'test-model');
mockForkStream([
modelChunk([{ text: 'private thought', thought: true }]),
modelChunk([fnCall('edit', { file: 'a.ts' })]),
modelChunk([{ text: ' first ' }]),
modelChunk([{ text: 'answer ' }], undefined, USAGE),
]);
const onText = vi.fn();
const result = await runFork({ onText });
expect(onText.mock.calls).toEqual([[' first '], ['answer ']]);
expect(result.text).toBe('first answer');
expect(result.usage).toMatchObject({ inputTokens: 10, outputTokens: 5 });
});
it('awaits answer delivery before consuming the next chunk', async () => {
saveCacheSafeParams({}, [], 'test-model');
mockForkStream([
modelChunk([{ text: 'first' }]),
modelChunk([{ text: 'second' }]),
]);
let release!: () => void;
const delivered = new Promise<void>((resolve) => {
release = resolve;
});
const onText = vi.fn().mockImplementationOnce(() => delivered);
const result = runFork({ onText });
await vi.waitFor(() => expect(onText).toHaveBeenCalledOnce());
expect(onText).toHaveBeenCalledWith('first');
release();
await expect(result).resolves.toMatchObject({ text: 'firstsecond' });
expect(onText.mock.calls).toEqual([['first'], ['second']]);
});
it('propagates cancellation during answer delivery without later callbacks', async () => {
saveCacheSafeParams({}, [], 'test-model');
const send = mockForkStream([
modelChunk([{ text: 'first' }]),
modelChunk([{ text: 'second' }]),
]);
const controller = new AbortController();
const onText = vi.fn(() => {
controller.abort();
controller.signal.throwIfAborted();
});
await expect(
runFork({ onText, abortSignal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
expect(sentConfig(send).abortSignal).toBe(controller.signal);
expect(onText.mock.calls).toEqual([['first']]);
});
it('keeps the first structured response when the provider emits another schema call', async () => {
saveCacheSafeParams(
{ tools: [{ functionDeclarations: [{ name: 'edit' }] }] },
+6 -1
View File
@@ -405,6 +405,8 @@ export interface CachePathParams {
abortSignal?: AbortSignal;
/** Do not route the query through configured model fallbacks. */
disableModelFallbacks?: boolean;
/** Receives answer text as it arrives, excluding thoughts and tool calls. */
onText?: (text: string) => void | Promise<void>;
/**
* When true, keep the parent's tools in the per-request config so the
* Anthropic prompt-cache key (system + tools) matches the main agent's.
@@ -644,7 +646,10 @@ export async function runForkedAgent(
.filter((p) => !(p as Record<string, unknown>)['functionCall'])
.map((p) => p.text ?? '')
.join('');
if (text) fullText += text;
if (text) {
fullText += text;
if (params.onText) await params.onText(text);
}
if (response.usageMetadata)
usage = extractQueryUsage(response.usageMetadata);
}
@@ -212,6 +212,7 @@ import type {
DaemonMcpManageAction,
DaemonMcpManageResult,
DaemonSessionBtwResult,
DaemonSessionBtwStreamEvent,
DaemonSessionAttachmentData,
DaemonSessionAttachmentReference,
DaemonSessionGenerationEvent,
@@ -294,7 +295,7 @@ import type {
DaemonWorkspaceTrustStatusV2,
DaemonUnarchiveSessionsResult,
} from './types.js';
import { parseSseStream } from './sse.js';
import { parseSseStream, SseFramingError } from './sse.js';
import {
DaemonStandaloneCreationOutcomeUnknownError,
STANDALONE_SESSION_OPTIONS_CAPABILITY,
@@ -4571,6 +4572,151 @@ export class DaemonClient {
);
}
/**
* Stream answer snapshots; reconnects resume the same model request.
* Callers must preflight `capabilities.features` for `session_btw_stream`.
*/
async *streamSessionBtw(
sessionId: string,
question: string,
opts?: { signal?: AbortSignal; clientId?: string },
): AsyncGenerator<DaemonSessionBtwStreamEvent> {
opts?.signal?.throwIfAborted();
const requestId = createStandaloneSessionId();
const url = `${this.baseUrl}/session/${urlEncode(sessionId)}/btw/stream`;
let resume = false;
let terminal = false;
let retryDelay = 1_000;
let reconnectDeadline: number | undefined;
let lastReconnectError: unknown;
try {
while (true) {
opts?.signal?.throwIfAborted();
if (reconnectDeadline !== undefined && Date.now() >= reconnectDeadline)
throw lastReconnectError;
const connection = new AbortController();
const signal = opts?.signal
? composeAbortSignals([opts.signal, connection.signal])
: connection.signal;
let timer: ReturnType<typeof setTimeout>;
const armTimeout = () => {
clearTimeout(timer);
timer = setTimeout(
() => {
connection.abort(
new DOMException('BTW connection timed out', 'TimeoutError'),
);
},
Math.min(
45_000,
reconnectDeadline === undefined
? 45_000
: Math.max(1, reconnectDeadline - Date.now()),
),
);
};
armTimeout();
try {
const res = await this._fetch(url, {
method: 'POST',
headers: this.headers(
{
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
opts?.clientId,
),
body: JSON.stringify({
question,
requestId,
...(resume ? { resume: true } : {}),
}),
signal,
});
if (!res.ok)
throw await this.failOnError(res, 'POST /session/:id/btw/stream');
resume = true;
if (!res.body)
throw new SseFramingError('BTW response body is missing');
const body = res.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
armTimeout();
controller.enqueue(chunk);
},
}),
);
for await (const raw of parseSseStream(body, signal)) {
const event = raw as unknown as Record<string, unknown>;
if (
!event ||
typeof event !== 'object' ||
Array.isArray(event) ||
event['v'] !== 1 ||
event['requestId'] !== requestId ||
!(
((event['type'] === 'answer' || event['type'] === 'done') &&
typeof event['answer'] === 'string') ||
(event['type'] === 'error' &&
typeof event['code'] === 'string' &&
typeof event['message'] === 'string')
)
) {
throw new SseFramingError('Invalid BTW stream event');
}
reconnectDeadline = undefined;
armTimeout();
retryDelay = 1_000;
terminal = event['type'] === 'done' || event['type'] === 'error';
yield event as unknown as DaemonSessionBtwStreamEvent;
if (terminal) return;
}
throw new Error('BTW stream ended before completion');
} catch (error) {
opts?.signal?.throwIfAborted();
if (
error instanceof SseFramingError ||
(error instanceof DaemonHttpError &&
error.status !== 408 &&
error.status !== 429 &&
error.status < 500) ||
(reconnectDeadline !== undefined && Date.now() >= reconnectDeadline)
)
throw error;
lastReconnectError = error;
reconnectDeadline ??= Date.now() + 60_000;
} finally {
clearTimeout(timer!);
connection.abort();
}
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(opts?.signal?.reason);
};
const timer = setTimeout(
() => {
opts?.signal?.removeEventListener('abort', onAbort);
resolve();
},
Math.min(retryDelay, Math.max(0, reconnectDeadline! - Date.now())),
);
opts?.signal?.addEventListener('abort', onAbort, { once: true });
if (opts?.signal?.aborted) onAbort();
});
retryDelay = Math.min(retryDelay * 2, 5_000);
}
} finally {
if (!terminal) {
void this._fetch(`${url}/${urlEncode(requestId)}`, {
method: 'DELETE',
headers: this.headers({}, opts?.clientId),
signal: abortTimeout(5_000),
}).catch(() => undefined);
}
}
}
async btwSession(
sessionId: string,
question: string,
@@ -29,6 +29,7 @@ import type {
DaemonRewindResult,
DaemonRewindSnapshotInfo,
DaemonSessionBtwResult,
DaemonSessionBtwStreamEvent,
DaemonSessionAgentsStatus,
DaemonAgentTrace,
DaemonSessionAttachmentData,
@@ -1066,6 +1067,16 @@ export class DaemonSessionClient {
});
}
btwStream(
question: string,
opts?: { signal?: AbortSignal },
): AsyncGenerator<DaemonSessionBtwStreamEvent> {
return this.client.streamSessionBtw(this.sessionId, question, {
...opts,
...(this.clientId ? { clientId: this.clientId } : {}),
});
}
btw(
question: string,
opts?: { signal?: AbortSignal },
@@ -478,6 +478,7 @@ export type {
DaemonRewindResult,
DaemonRewindSnapshotInfo,
DaemonSessionBtwResult,
DaemonSessionBtwStreamEvent,
DaemonSessionGenerationEvent,
DaemonMidTurnMessageResult,
DaemonMidTurnMessageSummary,
@@ -4539,6 +4539,10 @@ export interface DaemonSessionRecapResult {
recap: string | null;
}
export type DaemonSessionBtwStreamEvent =
| { v: 1; type: 'answer' | 'done'; requestId: string; answer: string }
| { v: 1; type: 'error'; requestId: string; code: string; message: string };
export type DaemonSessionGenerationEvent =
| {
v: 1;
@@ -0,0 +1,442 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DaemonClient } from '../../src/daemon/DaemonClient.js';
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
function snapshots(
requestId: string,
events: Array<{
type: string;
answer?: string;
code?: string;
message?: string;
}>,
) {
return new Response(
events
.map(
(event) => `data: ${JSON.stringify({ v: 1, requestId, ...event })}\n\n`,
)
.join(''),
{ headers: { 'Content-Type': 'text/event-stream' } },
);
}
function body(init?: RequestInit) {
return JSON.parse(String(init?.body)) as {
requestId: string;
question: string;
resume?: boolean;
};
}
afterEach(() => vi.useRealTimers());
describe('BTW answer stream', () => {
it('uses native REST with ACP transports and preserves complete answer snapshots', async () => {
const restFetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) =>
snapshots(body(init).requestId, [
{ type: 'answer', answer: 'First' },
{ type: 'done', answer: 'First second' },
]),
);
const rpcFetch = vi.fn();
const transport = {
restFetch,
fetch: rpcFetch,
} as unknown as DaemonTransport;
const client = new DaemonClient({
baseUrl: 'http://daemon',
transport,
token: 'token',
});
const events = [];
for await (const event of client.streamSessionBtw('session/1', 'Question', {
clientId: 'client-1',
}))
events.push(event);
expect(
events.map((event) => event.type !== 'error' && event.answer),
).toEqual(['First', 'First second']);
expect(rpcFetch).not.toHaveBeenCalled();
expect(restFetch).toHaveBeenCalledOnce();
expect(restFetch.mock.calls[0]?.[0]).toBe(
'http://daemon/session/session%2F1/btw/stream',
);
const headers = new Headers(restFetch.mock.calls[0]?.[1]?.headers);
expect(headers.get('Authorization')).toBe('Bearer token');
expect(headers.get('X-Qwen-Client-Id')).toBe('client-1');
});
it('reconnects an interrupted stream with the same request ID and resume flag', async () => {
vi.useFakeTimers();
let attempts = 0;
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const { requestId } = body(init);
return snapshots(
requestId,
++attempts === 1
? [{ type: 'answer', answer: 'First' }]
: [
{ type: 'answer', answer: 'First second' },
{ type: 'done', answer: 'First second' },
],
);
});
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const stream = client.streamSessionBtw('session-1', 'Question');
expect((await stream.next()).value).toMatchObject({
type: 'answer',
answer: 'First',
});
const next = stream.next();
await vi.advanceTimersByTimeAsync(1_000);
expect((await next).value).toMatchObject({
type: 'answer',
answer: 'First second',
});
expect((await stream.next()).value).toMatchObject({
type: 'done',
answer: 'First second',
});
await stream.next();
expect(body(fetch.mock.calls[0]?.[1])).toEqual({
question: 'Question',
requestId: expect.any(String),
});
expect(body(fetch.mock.calls[1]?.[1])).toEqual({
...body(fetch.mock.calls[0]?.[1]),
resume: true,
});
expect(fetch).toHaveBeenCalledTimes(2);
});
it.each(['network error', 429, 503] as const)(
'retries before acceptance with the same request ID after %s',
async (failure) => {
vi.useFakeTimers();
let attempts = 0;
const fetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) => {
if (++attempts === 1) {
if (failure === 'network error')
throw new TypeError('network lost');
return new Response('{}', { status: failure });
}
const { requestId, resume } = body(init);
if (resume) return new Response('{}', { status: 410 });
return snapshots(requestId, [{ type: 'done', answer: 'Recovered' }]);
},
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
const next = stream.next();
await vi.advanceTimersByTimeAsync(1_000);
expect((await next).value).toMatchObject({
type: 'done',
answer: 'Recovered',
});
await stream.next();
expect(fetch).toHaveBeenCalledTimes(2);
expect(body(fetch.mock.calls[1]?.[1])).toEqual(
body(fetch.mock.calls[0]?.[1]),
);
expect(body(fetch.mock.calls[1]?.[1])).not.toHaveProperty('resume');
expect(vi.getTimerCount()).toBe(0);
},
);
it('reuses accepted model work when its first response was lost before headers', async () => {
vi.useFakeTimers();
const requests = new Set<string>();
let generations = 0;
let attempts = 0;
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const { requestId } = body(init);
if (!requests.has(requestId)) {
requests.add(requestId);
generations++;
}
if (++attempts === 1) throw new TypeError('response lost');
return snapshots(requestId, [{ type: 'done', answer: 'One generation' }]);
});
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
const next = stream.next();
await vi.advanceTimersByTimeAsync(1_000);
expect((await next).value).toMatchObject({
type: 'done',
answer: 'One generation',
});
await stream.next();
expect(generations).toBe(1);
expect(fetch).toHaveBeenCalledTimes(2);
expect(body(fetch.mock.calls[1]?.[1])).toEqual(
body(fetch.mock.calls[0]?.[1]),
);
expect(body(fetch.mock.calls[1]?.[1])).not.toHaveProperty('resume');
});
it.each(['headers only', 'partial snapshot'] as const)(
'does not recreate an expired request accepted with %s',
async (stage) => {
vi.useFakeTimers();
let attempts = 0;
const fetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'DELETE')
return new Response(null, { status: 204 });
if (++attempts === 1)
return snapshots(
body(init).requestId,
stage === 'partial snapshot'
? [{ type: 'answer', answer: 'Partial' }]
: [],
);
return new Response('{}', { status: 410 });
},
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
if (stage === 'partial snapshot') {
expect((await stream.next()).value).toMatchObject({
type: 'answer',
answer: 'Partial',
});
}
const failed = stream.next().catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(1_000);
expect(await failed).toMatchObject({ status: 410 });
const posts = fetch.mock.calls.filter(
(call) => call[1]?.method === 'POST',
);
expect(posts).toHaveLength(2);
expect(body(posts[1]?.[1])).toEqual({
...body(posts[0]?.[1]),
resume: true,
});
await vi.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(3);
expect(fetch.mock.calls[2]?.[1]?.method).toBe('DELETE');
},
);
it.each([false, true])(
'cancels during reconnect backoff without another connection (accepted=%s)',
async (accepted) => {
vi.useFakeTimers();
const controller = new AbortController();
const fetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'DELETE')
return new Response(null, { status: 204 });
if (!accepted) throw new TypeError('network lost');
return snapshots(body(init).requestId, [
{ type: 'answer', answer: 'First' },
]);
},
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question', { signal: controller.signal });
let next = stream.next();
if (accepted) {
await next;
next = stream.next();
}
const failed = next.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(1);
controller.abort();
expect(await failed).toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(5_000);
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls[1]?.[1]?.method).toBe('DELETE');
expect(String(fetch.mock.calls[1]?.[0])).toContain(
body(fetch.mock.calls[0]?.[1]).requestId,
);
expect(vi.getTimerCount()).toBe(0);
},
);
it('cancels generation when a consumer stops reading before done', async () => {
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) =>
init?.method === 'DELETE'
? new Response(null, { status: 204 })
: snapshots(body(init).requestId, [
{ type: 'answer', answer: 'First' },
]),
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
await stream.next();
await stream.return(undefined);
expect(fetch.mock.calls[1]?.[1]?.method).toBe('DELETE');
});
it('does not retry application errors', async () => {
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) =>
snapshots(body(init).requestId, [
{ type: 'error', code: 'failed', message: 'Model failed' },
]),
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
expect((await stream.next()).value).toMatchObject({
type: 'error',
message: 'Model failed',
});
expect((await stream.next()).done).toBe(true);
expect(fetch).toHaveBeenCalledOnce();
});
it('keeps a quiet model connected while heartbeat bytes continue arriving', async () => {
vi.useFakeTimers();
let writer!: ReadableStreamDefaultController<Uint8Array>;
let requestId = '';
const encoder = new TextEncoder();
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
requestId = body(init).requestId;
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
writer = controller;
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ v: 1, type: 'answer', requestId, answer: '' })}\n\n`,
),
);
},
}),
);
});
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
await stream.next();
const done = stream.next();
for (let n = 0; n < 5; n++) {
await vi.advanceTimersByTimeAsync(15_000);
writer.enqueue(encoder.encode(': heartbeat\n\n'));
}
expect(fetch).toHaveBeenCalledOnce();
writer.enqueue(
encoder.encode(
`data: ${JSON.stringify({ v: 1, type: 'done', requestId, answer: 'Slow answer' })}\n\n`,
),
);
expect((await done).value).toMatchObject({
type: 'done',
answer: 'Slow answer',
});
await stream.next();
});
it('reconnects when the connection silently stops delivering bytes', async () => {
vi.useFakeTimers();
let attempts = 0;
const fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const requestId = body(init).requestId;
if (++attempts > 1)
return snapshots(requestId, [{ type: 'done', answer: 'Recovered' }]);
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ v: 1, type: 'answer', requestId, answer: 'Partial' })}\n\n`,
),
);
},
}),
);
});
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
await stream.next();
const done = stream.next();
await vi.advanceTimersByTimeAsync(46_000);
expect((await done).value).toMatchObject({
type: 'done',
answer: 'Recovered',
});
await stream.next();
expect(fetch).toHaveBeenCalledTimes(2);
expect(body(fetch.mock.calls[1]?.[1]).resume).toBe(true);
});
it.each(['network', 'http'] as const)(
'does not start another POST at the reconnect deadline after %s failure',
async (failure) => {
vi.useFakeTimers();
vi.setSystemTime(0);
const posts: number[] = [];
const unavailable = new TypeError('Daemon unreachable');
const fetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'DELETE')
return new Response(null, { status: 204 });
posts.push(Date.now());
if (failure === 'network') throw unavailable;
return new Response('{}', { status: 503 });
},
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
let settled = false;
const failed = stream.next().catch((error: unknown) => {
settled = true;
return error;
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
const attemptsBeforeDeadline = posts.length;
await vi.advanceTimersByTimeAsync(1);
const error = await failed;
if (failure === 'network') expect(error).toBe(unavailable);
else expect(error).toMatchObject({ status: 503 });
expect(posts).not.toHaveLength(0);
expect(posts.every((at) => at < 60_000)).toBe(true);
expect(posts).toHaveLength(attemptsBeforeDeadline);
expect(fetch.mock.calls.at(-1)?.[1]?.method).toBe('DELETE');
expect(vi.getTimerCount()).toBe(0);
},
);
it('bounds reconnect attempts when the daemon remains unavailable', async () => {
vi.useFakeTimers();
const fetch = vi.fn(
async (_url: RequestInfo | URL, init?: RequestInit) =>
new Response(null, { status: init?.method === 'DELETE' ? 204 : 503 }),
);
const stream = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
}).streamSessionBtw('s', 'Question');
const failed = stream.next().catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(60_000);
expect(await failed).toMatchObject({ status: 503 });
const posts = fetch.mock.calls.filter((call) => call[1]?.method === 'POST');
expect(posts.length).toBeLessThan(20);
expect(posts.every((call) => body(call[1]).resume === undefined)).toBe(
true,
);
expect(new Set(posts.map((call) => body(call[1]).requestId)).size).toBe(1);
expect(fetch.mock.calls.at(-1)?.[1]?.method).toBe('DELETE');
expect(vi.getTimerCount()).toBe(0);
});
});
+112 -9
View File
@@ -45,6 +45,7 @@ import {
DaemonHttpError,
GOAL_PAUSE_REASON_COMMAND,
type DaemonInputAnnotation,
type DaemonSessionBtwStreamEvent,
type DaemonSessionArtifact,
type DaemonSessionAttachmentReference,
type DaemonSessionSummary,
@@ -693,7 +694,16 @@ const {
setDaemonActivePrompt: vi.fn(),
sendPrompt: vi.fn().mockResolvedValue(undefined),
continueSession: vi.fn().mockResolvedValue(undefined),
btwSession: vi.fn().mockResolvedValue({ answer: 'side answer' }),
btwSessionStream: vi.fn(
async function* (): AsyncGenerator<DaemonSessionBtwStreamEvent> {
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: 'side answer',
} as const;
},
),
generateSessionContent: vi.fn(async function* () {}),
createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
attachSession: vi.fn().mockResolvedValue(undefined),
@@ -12470,7 +12480,14 @@ beforeEach(() => {
if (typeof value === 'function' && 'mockClear' in value) value.mockClear();
}
mockSessionActions.sendPrompt.mockResolvedValue(undefined);
mockSessionActions.btwSession.mockResolvedValue({ answer: 'side answer' });
mockSessionActions.btwSessionStream.mockImplementation(async function* () {
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: 'side answer',
} as const;
});
mockSessionActions.createSession.mockResolvedValue({
sessionId: 'session-1',
});
@@ -29231,7 +29248,7 @@ describe('App session callbacks', () => {
await Promise.resolve();
});
expect(mockSessionActions.btwSession).toHaveBeenCalledWith(
expect(mockSessionActions.btwSessionStream).toHaveBeenCalledWith(
sideQuestion,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
@@ -29358,7 +29375,7 @@ describe('App session callbacks', () => {
await Promise.resolve();
});
expect(mockSessionActions.btwSession).not.toHaveBeenCalled();
expect(mockSessionActions.btwSessionStream).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
});
@@ -32443,7 +32460,7 @@ describe('App session callbacks', () => {
await flush();
expect(mockSessionActions.forkSession).not.toHaveBeenCalled();
expect(mockSessionActions.btwSession).toHaveBeenCalledWith(
expect(mockSessionActions.btwSessionStream).toHaveBeenCalledWith(
'explain the current implementation',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
@@ -32525,9 +32542,95 @@ describe('App session callbacks', () => {
).toBe(true);
});
it('replaces partial BTW text with the completed snapshot', async () => {
const finish = deferred<void>();
mockSessionActions.btwSessionStream.mockImplementationOnce(
async function* () {
yield {
v: 1,
type: 'answer',
requestId: 'btw-request',
answer: 'Partial answer',
} as const;
await finish.promise;
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: 'Partial answer, complete.',
} as const;
},
);
const { container } = renderApp();
await flush();
testState.prompt = '/btw Explain';
await clickSubmit(container);
await flush();
expect(testState.latestBtwMessageProps).toMatchObject({
answer: 'Partial answer',
isPending: true,
});
await act(async () => {
finish.resolve();
await finish.promise;
});
expect(testState.latestBtwMessageProps).toMatchObject({
answer: 'Partial answer, complete.',
isPending: false,
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('keeps received BTW text after a terminal failure', async () => {
const fail = deferred<void>();
mockSessionActions.btwSessionStream.mockImplementationOnce(
async function* () {
yield {
v: 1,
type: 'answer',
requestId: 'request',
answer: 'Already received text',
} as const;
await fail.promise;
throw Object.assign(new Error('Stream failed'), {
_alreadyDispatched: true,
});
},
);
const { container } = renderApp();
await flush();
testState.prompt = '/btw Explain';
await clickSubmit(container);
await flush();
expect(testState.latestBtwMessageProps).toMatchObject({
answer: 'Already received text',
isPending: true,
});
await act(async () => {
fail.resolve();
await fail.promise;
});
await flush();
expect(testState.latestBtwMessageProps).toMatchObject({
answer: 'Already received text',
isPending: false,
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('settles visible btw after a same-id attachment replacement', async () => {
const btw = deferred<{ answer: string }>();
mockSessionActions.btwSession.mockReturnValueOnce(btw.promise);
mockSessionActions.btwSessionStream.mockImplementationOnce(
async function* () {
const result = await btw.promise;
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: result.answer,
} as const;
},
);
const { container, rerender } = renderApp();
await flush();
@@ -32564,7 +32667,7 @@ describe('App session callbacks', () => {
await flush();
expect(mockSessionActions.forkSession).not.toHaveBeenCalled();
expect(mockSessionActions.btwSession).not.toHaveBeenCalled();
expect(mockSessionActions.btwSessionStream).not.toHaveBeenCalled();
expect(container.querySelector('button[title="Side task"]')).not.toBeNull();
});
@@ -33253,7 +33356,7 @@ describe('App session callbacks', () => {
{ name: 'Side task' },
'client-1',
);
expect(mockSessionActions.btwSession).not.toHaveBeenCalled();
expect(mockSessionActions.btwSessionStream).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockWorkspace.client.detachSession).toHaveBeenCalledWith(
'secondary-side-task',
@@ -33353,7 +33456,7 @@ describe('App session callbacks', () => {
await clickSubmit(container);
await flush();
expect(mockSessionActions.btwSession).toHaveBeenCalledWith(
expect(mockSessionActions.btwSessionStream).toHaveBeenCalledWith(
'side explain the current implementation',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
+33 -24
View File
@@ -13242,39 +13242,47 @@ export function App({
isPending: true,
});
sessionActions
.btwSession(question, { signal: abortController.signal })
.then(
(result) => {
void (async () => {
try {
for await (const event of sessionActions.btwSessionStream(question, {
signal: abortController.signal,
})) {
if (
currentSessionIdRef.current !== sessionId ||
connectionRef.current.workspaceCwd !== workspaceCwd
connectionRef.current.workspaceCwd !== workspaceCwd ||
btwAbortControllerRef.current !== abortController
)
return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
const done = event.type === 'done';
setBtwMessage({
id: messageId,
role: 'btw',
question,
answer: result.answer || t('btw.emptyAnswer'),
isPending: false,
answer: event.answer || (done ? t('btw.emptyAnswer') : ''),
isPending: !done,
});
},
(error: unknown) => {
if (
currentSessionIdRef.current !== sessionId ||
connectionRef.current.workspaceCwd !== workspaceCwd
)
return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage(null);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled btw failure', error);
}
},
);
if (done) btwAbortControllerRef.current = null;
}
} catch (error) {
if (
currentSessionIdRef.current !== sessionId ||
connectionRef.current.workspaceCwd !== workspaceCwd ||
btwAbortControllerRef.current !== abortController
)
return;
btwAbortControllerRef.current = null;
setBtwMessage((current) =>
current?.id === messageId &&
current.role === 'btw' &&
current.answer
? { ...current, isPending: false }
: null,
);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled btw failure', error);
}
}
})();
},
[
connection.sessionId,
@@ -23762,6 +23770,7 @@ export function App({
btwMessage?.role === 'btw' ? (
<div className={styles.btwPanel}>
<BtwMessage
key={btwMessage.id}
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import { BtwMessage } from './BtwMessage';
vi.mock('../../i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock('./Markdown', () => ({
Markdown: ({ content }: { content: string }) => <span>{content}</span>,
}));
it.each(['', 'Partial answer'])(
'shows pending hints with answer snapshot %j',
(answer) => {
const container = document.createElement('div');
const root = createRoot(container);
try {
act(() =>
root.render(
<BtwMessage question="Question" answer={answer} isPending />,
),
);
if (answer) expect(container.textContent).toContain(answer);
expect(container.textContent).toContain('btw.shortcuts.pending');
expect(container.textContent?.includes('btw.answering')).toBe(!answer);
} finally {
act(() => root.unmount());
}
},
);
it('follows answer growth, pauses when scrolled up, and resumes at the bottom', () => {
let resize = () => {};
const disconnect = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: () => void) {
resize = callback;
}
observe() {}
disconnect = disconnect;
},
);
const container = document.createElement('div');
const root = createRoot(container);
const render = (answer: string) =>
act(() =>
root.render(<BtwMessage question="Question" answer={answer} isPending />),
);
try {
render('');
const viewport = container.querySelector<HTMLElement>(
'[data-web-shell-btw-content]',
)!;
let height = 200;
let top = 0;
Object.defineProperties(viewport, {
clientHeight: { get: () => 80 },
scrollHeight: { get: () => height },
scrollTop: {
get: () => top,
set: (value: number) => {
top = Math.min(Math.max(0, value), height - 80);
},
},
});
render('First chunk');
expect(top).toBe(120);
act(() => {
viewport.scrollTop = 40;
viewport.dispatchEvent(new Event('scroll'));
});
height = 300;
render('First chunk, more content');
expect(top).toBe(40);
act(() => resize());
expect(top).toBe(40);
act(() => {
viewport.scrollTop = 220;
viewport.dispatchEvent(new Event('scroll'));
});
height = 400;
render('First chunk, more content, newest content');
expect(top).toBe(320);
height = 450;
act(() => resize());
expect(top).toBe(370);
act(() => {
viewport.scrollTop = 367;
viewport.dispatchEvent(new Event('scroll'));
});
height = 500;
act(() => resize());
expect(top).toBe(367);
act(() => {
viewport.scrollTop = 418;
viewport.dispatchEvent(new Event('scroll'));
});
height = 550;
act(() => resize());
expect(top).toBe(470);
} finally {
act(() => root.unmount());
vi.unstubAllGlobals();
}
expect(disconnect).toHaveBeenCalledOnce();
});
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { useI18n } from '../../i18n';
import { Markdown } from './Markdown';
import styles from './BtwMessage.module.css';
@@ -15,16 +15,42 @@ export const BtwMessage = memo(function BtwMessage({
isPending,
}: BtwMessageProps) {
const { t } = useI18n();
const contentRef = useRef<HTMLDivElement>(null);
const answerRef = useRef<HTMLDivElement>(null);
const following = useRef(true);
const followAnswer = useCallback(() => {
const content = contentRef.current;
if (content && following.current) content.scrollTop = content.scrollHeight;
}, []);
useLayoutEffect(followAnswer, [answer, isPending, followAnswer]);
useEffect(() => {
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(followAnswer);
if (contentRef.current) observer.observe(contentRef.current);
if (answerRef.current) observer.observe(answerRef.current);
return () => observer.disconnect();
}, [followAnswer]);
return (
<div className={styles.message}>
<div className={styles.content}>
<div
ref={contentRef}
className={styles.content}
data-web-shell-btw-content
onScroll={(event) => {
const content = event.currentTarget;
following.current =
content.scrollHeight - content.clientHeight - content.scrollTop <=
2;
}}
>
<div className={styles.question}>
<span className={styles.prefix}>/btw </span>
<span>{question}</span>
</div>
<div className={styles.answer}>
{isPending ? (
<div ref={answerRef} className={styles.answer}>
{isPending && !answer ? (
<span className={styles.pending}>{t('btw.answering')}</span>
) : (
<Markdown content={answer} />
@@ -7,6 +7,7 @@ import {
DaemonStandaloneCreationOutcomeUnknownError,
DaemonTransportClosedError,
type DaemonCapabilities,
type DaemonSessionBtwStreamEvent,
type DaemonContinueSessionResult,
DaemonSessionClient,
type DaemonSessionContextStatus,
@@ -7151,6 +7152,17 @@ function createMockSession(
},
savedWorkflow: vi.fn(),
cancel: vi.fn(async () => undefined),
btw: vi.fn(async () => ({ sessionId, answer: 'Legacy answer' })),
btwStream: vi.fn(
async function* (): AsyncGenerator<DaemonSessionBtwStreamEvent> {
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: 'Side answer',
} as const;
},
),
context: vi.fn(async () => contextStatus(sessionId)),
detach: vi.fn(async () => undefined),
setModel: vi.fn(async () => ({})),
@@ -7876,3 +7888,218 @@ describe('model catalog refresh', () => {
},
);
});
describe('streaming side questions', () => {
const connection: DaemonConnectionState = {
status: 'connected',
workspaceCwd: '/workspace',
capabilities: { features: ['session_btw_stream'] } as DaemonCapabilities,
};
it.each([undefined, [], ['session_btw']])(
'uses legacy JSON when streaming is not advertised: %j',
async (features) => {
const session = createMockSession('session-a');
const legacyConnection: DaemonConnectionState = {
status: 'connected',
workspaceCwd: '/workspace',
...(features === undefined
? {}
: { capabilities: { features } as DaemonCapabilities }),
};
const { actions } = createActionsHarness({
session,
connection: legacyConnection,
});
const signal = new AbortController().signal;
const events = [];
for await (const event of actions.btwSessionStream('Question', {
signal,
}))
events.push(event);
expect(events).toEqual([{ type: 'done', answer: 'Legacy answer' }]);
expect(session.btw).toHaveBeenCalledWith('Question', { signal });
expect(session.btwStream).not.toHaveBeenCalled();
},
);
it('does not retry JSON after an advertised stream fails', async () => {
const session = createMockSession('session-a');
session.btwStream.mockImplementationOnce(async function* () {
yield await Promise.reject(new Error('Stream not found'));
});
const addNotice = vi.fn();
const { actions } = createActionsHarness({
session,
connection,
addNotice,
});
await expect(
actions.btwSessionStream('Question').next(),
).rejects.toMatchObject({ message: 'Stream not found' });
expect(session.btwStream).toHaveBeenCalledOnce();
expect(session.btw).not.toHaveBeenCalled();
expect(addNotice).toHaveBeenCalledOnce();
});
it('keeps streaming beyond the ordinary 30-second action deadline', async () => {
vi.useFakeTimers();
try {
const session = createMockSession('session-a');
const finish = createDeferred<void>();
session.btwStream.mockImplementationOnce(async function* () {
await finish.promise;
yield {
v: 1,
type: 'done',
requestId: 'btw-request',
answer: 'Slow answer',
} as const;
});
const addNotice = vi.fn();
const { actions } = createActionsHarness({
session,
addNotice,
connection,
});
const stream = actions.btwSessionStream('Question');
const answer = stream.next();
await vi.advanceTimersByTimeAsync(31_000);
expect(addNotice).not.toHaveBeenCalled();
finish.resolve();
expect((await answer).value).toMatchObject({
type: 'done',
answer: 'Slow answer',
});
await stream.next();
} finally {
vi.useRealTimers();
}
});
it.each(['btw_timeout', 'btw_cancelled', 'btw_failed'])(
'preserves terminal error code %s in the notice',
async (code) => {
const session = createMockSession('session-a');
const addNotice = vi.fn();
session.btwStream.mockImplementationOnce(async function* () {
yield {
v: 1,
type: 'error',
requestId: 'btw-request',
code,
message: 'Stream failed',
} as const;
});
const { actions } = createActionsHarness({
session,
addNotice,
connection,
});
await expect(
actions.btwSessionStream('Question').next(),
).rejects.toMatchObject({ message: 'Stream failed' });
expect(addNotice).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'btw_session',
code,
message: 'Side question failed: Stream failed',
}),
);
},
);
it.each([
[410, 'btw_expired'],
[409, 'btw_request_conflict'],
[429, 'btw_limit'],
[501, 'btw_stream_not_supported'],
] as const)(
'preserves HTTP %i error code %s in the notice',
async (status, code) => {
const session = createMockSession('session-a');
const error = new DaemonHttpError(status, { code }, 'Server explanation');
session.btwStream.mockImplementationOnce(async function* () {
yield await Promise.reject(error);
});
const addNotice = vi.fn();
const { actions } = createActionsHarness({
session,
connection,
addNotice,
});
await expect(actions.btwSessionStream('Question').next()).rejects.toBe(
error,
);
expect(addNotice).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'btw_session',
code,
message: 'Side question failed: Server explanation',
}),
);
expect(session.btw).not.toHaveBeenCalled();
},
);
it.each(['legacy action', 'stream fallback'] as const)(
'keeps the 30-second deadline for %s',
async (path) => {
vi.useFakeTimers();
try {
const session = createMockSession('session-a');
session.btw.mockImplementationOnce(() => new Promise(() => {}));
const addNotice = vi.fn();
const { actions } = createActionsHarness({
session,
addNotice,
connection: {
...connection,
capabilities: { features: [] } as DaemonCapabilities,
},
});
const result =
path === 'legacy action'
? actions.btwSession('Question')
: actions.btwSessionStream('Question').next();
const failed = result.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(29_999);
expect(addNotice).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(await failed).toMatchObject({
message: 'Side question timed out after 30000ms',
});
expect(addNotice).toHaveBeenCalledOnce();
expect(session.btwStream).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
},
);
it('reports a stream that ends without a terminal snapshot', async () => {
const session = createMockSession('session-a');
session.btwStream.mockImplementationOnce(async function* () {
yield {
v: 1,
type: 'answer',
requestId: 'request',
answer: 'Partial',
} as const;
});
const addNotice = vi.fn();
const { actions } = createActionsHarness({
session,
connection,
addNotice,
});
const stream = actions.btwSessionStream('Question');
expect((await stream.next()).value).toEqual({
type: 'answer',
answer: 'Partial',
});
await expect(stream.next()).rejects.toThrow('ended before completion');
expect(addNotice).toHaveBeenCalledOnce();
expect(session.btw).not.toHaveBeenCalled();
});
});
@@ -2872,6 +2872,54 @@ export function createDaemonSessionActions({
}
},
async *btwSessionStream(
question: string,
opts?: { signal?: AbortSignal },
): AsyncGenerator<{ type: 'answer' | 'done'; answer: string }> {
const session = requireSessionForAction(
addNotice,
sessionRef.current,
'Side question failed',
'btw_session',
);
let code: string | undefined;
try {
if (
getConnection().capabilities?.features?.includes(
'session_btw_stream',
) !== true
) {
const result = await withActionTimeout(
session.btw(question, opts),
'Side question timed out',
);
yield {
type: 'done',
answer: result.answer ?? '',
};
return;
}
for await (const event of session.btwStream(question, opts)) {
if (event.type === 'error') {
code = event.code;
throw new Error(event.message);
}
yield { type: event.type, answer: event.answer };
if (event.type === 'done') return;
}
throw new Error('Side question stream ended before completion');
} catch (error) {
if (opts?.signal?.aborted || isAbortError(error)) throw error;
throw dispatchActionError(
addNotice,
'Side question failed',
error,
'btw_session',
{ code: code ?? getDaemonErrorCode(error) },
);
}
},
async btwSession(
question: string,
opts?: { signal?: AbortSignal },
@@ -3768,6 +3816,7 @@ function dispatchActionError(
opts?: {
dispatchedNoticeKeys?: Set<string>;
noticeOnceKey?: string;
code?: string;
},
): Error {
if (isAbortError(error)) {
@@ -3787,7 +3836,7 @@ function dispatchActionError(
operation,
code: isAcpChildCapacityError(error)
? 'acp_child_capacity_exhausted'
: `daemon.${operation}.failed`,
: (opts?.code ?? `daemon.${operation}.failed`),
message: `${action}: ${message}`,
debugMessage: message,
recoverable: true,
@@ -645,6 +645,11 @@ export interface DaemonSessionActions {
silent?: boolean;
},
): Promise<DaemonRewindResult>;
btwSessionStream(
question: string,
opts?: { signal?: AbortSignal },
): AsyncGenerator<{ type: 'answer' | 'done'; answer: string }>;
/** Legacy JSON action retained for existing daemon-react-sdk consumers. */
btwSession(
question: string,
opts?: { signal?: AbortSignal },
@@ -308,6 +308,7 @@ export function createWebShellDaemonScenario(
'session_permission_vote',
'session_scope_override',
'session_source_metadata',
'session_btw_stream',
'workspace_settings',
'workspace_voice',
],
@@ -886,6 +887,7 @@ function isDaemonPath(path: string): boolean {
/^\/session\/[^/]+\/saved-workflows\/[^/]+\/?$/.test(path) ||
/^\/session\/[^/]+\/mid-turn-message\/?$/.test(path) ||
/^\/session\/[^/]+\/mid-turn-messages(?:\/[^/]+)?\/?$/.test(path) ||
/^\/session\/[^/]+\/btw\/stream(?:\/[^/]+)?\/?$/.test(path) ||
/^\/session\/[^/]+\/(load|resume|branch|prompt|permission\/[^/]+|context|supported-commands|events|model|config-option|approval-mode|heartbeat|cancel|detach|btw)\/?$/.test(
path,
)
@@ -1037,7 +1039,13 @@ function isDaemonRoute(method: string, path: string): boolean {
return true;
if (method === 'POST' && /^\/workspace\/github\/prs\/create\/?$/.test(path))
return true;
if (method === 'POST' && /^\/session\/[^/]+\/btw\/?$/.test(path)) return true;
if (method === 'POST' && /^\/session\/[^/]+\/btw(?:\/stream)?\/?$/.test(path))
return true;
if (
method === 'DELETE' &&
/^\/session\/[^/]+\/btw\/stream\/[^/]+$/.test(path)
)
return true;
if (method === 'GET' && /^\/file\/?$/.test(path)) return true;
if (method === 'GET' && /^\/session\/[^/]+\/artifacts\/?$/.test(path)) {
return true;
@@ -1967,6 +1975,28 @@ async function handleDaemonRoute(
);
return;
}
if (method === 'POST' && /^\/session\/[^/]+\/btw\/stream\/?$/.test(path)) {
const requestId = (body as Record<string, unknown>)['requestId'];
const answer = scenario.btwAnswer ?? 'A side answer.';
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: ['answer', 'done']
.map(
(type) =>
`event: ${type}\ndata: ${JSON.stringify({ v: 1, type, requestId, answer })}\n\n`,
)
.join(''),
});
return;
}
if (
method === 'DELETE' &&
/^\/session\/[^/]+\/btw\/stream\/[^/]+$/.test(path)
) {
await route.fulfill({ status: 204 });
return;
}
if (method === 'POST' && /^\/session\/[^/]+\/btw\/?$/.test(path)) {
await json(route, {
sessionId: path.split('/')[2],
@@ -36,14 +36,14 @@ test('mobile Add drawer prepares a side question and restores textarea focus @sm
await expect(editor).toBeFocused();
await expect(editor).toHaveValue('/btw What does this mean?');
expect(daemon.promptRequests()).toHaveLength(0);
expect(daemon.requests.filter((r) => r.path.endsWith('/btw'))).toHaveLength(
0,
);
expect(
daemon.requests.filter((r) => r.path.endsWith('/btw/stream')),
).toHaveLength(0);
await page.locator('[data-web-shell-composer-submit]').tap();
await expect(page.getByText(answer, { exact: true })).toBeVisible();
expect(
daemon.requests.filter(
(r) => r.method === 'POST' && r.path.endsWith('/btw'),
(r) => r.method === 'POST' && r.path.endsWith('/btw/stream'),
),
).toHaveLength(1);
expect(daemon.promptRequests()).toHaveLength(0);
@@ -55,6 +55,11 @@ test('mobile Add drawer prepares a side question and restores textarea focus @sm
path: testInfo.outputPath('btw-mobile-answer.png'),
});
expect(
daemon.requests.filter(
(request) => request.method === 'POST' && /\/btw$/.test(request.path),
),
).toHaveLength(0);
await page.getByTestId('composer-add-menu-trigger').tap();
await page.getByRole('button', { name: 'Shell mode', exact: true }).tap();
await page.getByTestId('composer-add-menu-trigger').tap();
@@ -8,8 +8,14 @@ import {
const EDITOR = '[data-web-shell-composer-surface] .cm-content';
const ANSWER = 'This side answer stays outside the main conversation.';
async function openSession(page: Page, testInfo: TestInfo) {
async function openSession(page: Page, testInfo: TestInfo, streaming = true) {
const scenario = createWebShellDaemonScenario({ btwAnswer: ANSWER });
if (!streaming) {
scenario.capabilities.features = scenario.capabilities.features.filter(
(feature) => feature !== 'session_btw_stream',
);
scenario.capabilities.features.push('session_btw');
}
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
@@ -57,9 +63,14 @@ for (const busy of [false, true]) {
await editor.press('Enter');
await expect(page.getByText(ANSWER, { exact: true })).toBeVisible();
const btwRequests = daemon.requests.filter(
(r) => r.method === 'POST' && r.path.endsWith('/btw'),
(r) => r.method === 'POST' && r.path.endsWith('/btw/stream'),
);
expect(btwRequests).toHaveLength(1);
expect(
daemon.requests.filter(
(request) => request.method === 'POST' && /\/btw$/.test(request.path),
),
).toHaveLength(0);
expect(btwRequests[0]?.body).toMatchObject({ question });
expect(daemon.promptRequests()).toHaveLength(busy ? 1 : 0);
expect(
@@ -100,9 +111,9 @@ test('prepares empty drafts and preserves an existing BTW prefix', async ({
await expect(editor).toHaveText(expected);
}
expect(daemon.promptRequests()).toHaveLength(0);
expect(daemon.requests.filter((r) => r.path.endsWith('/btw'))).toHaveLength(
0,
);
expect(
daemon.requests.filter((r) => r.path.endsWith('/btw/stream')),
).toHaveLength(0);
});
test('disables the BTW entry with an explanation while a file is attached', async ({
@@ -131,3 +142,322 @@ test('disables the BTW entry with an explanation while a file is attached', asyn
);
expect(daemon.promptRequests()).toHaveLength(0);
});
test('shows a partial BTW answer and replaces it with the resumed snapshot', async ({
page,
}, testInfo) => {
const daemon = await openSession(page, testInfo);
const requests: Array<{
question: string;
requestId: string;
resume?: boolean;
}> = [];
let finish: (() => void) | undefined;
const resumed = new Promise<void>((resolve) => {
finish = resolve;
});
await page.route('**/btw/stream', async (route) => {
const body = route.request().postDataJSON() as {
question: string;
requestId: string;
resume?: boolean;
};
requests.push(body);
if (requests.length > 1) await resumed;
const events =
requests.length === 1
? [{ type: 'answer', answer: 'Partial BTW answer' }]
: [{ type: 'done', answer: 'Partial BTW answer, now complete.' }];
await route.fulfill({
contentType: 'text/event-stream',
body: events
.map(
(event) =>
`data: ${JSON.stringify({ v: 1, requestId: body.requestId, ...event })}\n\n`,
)
.join(''),
});
});
await page.locator(EDITOR).fill('/btw Explain this');
await page.locator(EDITOR).press('Enter');
await expect(
page.getByText('Partial BTW answer', { exact: true }),
).toBeVisible();
await page.screenshot({
animations: 'disabled',
path: testInfo.outputPath('btw-partial-answer.png'),
});
await expect.poll(() => requests.length).toBe(2);
expect(requests[1]).toEqual({ ...requests[0], resume: true });
finish?.();
await expect(
page.getByText('Partial BTW answer, now complete.', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Partial BTW answer', { exact: true }),
).toHaveCount(0);
await expect(page.locator('[data-web-shell-message-list]')).not.toContainText(
'Partial BTW answer',
);
expect(daemon.promptRequests()).toHaveLength(0);
await page.screenshot({
animations: 'disabled',
path: testInfo.outputPath('btw-resumed-answer.png'),
});
});
test('uses legacy JSON when streaming BTW is not advertised', async ({
page,
}, testInfo) => {
const daemon = await openSession(page, testInfo, false);
await page.locator(EDITOR).fill('/btw Explain this');
await page.locator(EDITOR).press('Enter');
await expect(page.getByText(ANSWER, { exact: true })).toBeVisible();
const calls = daemon.requests.filter(
(request) =>
request.method === 'POST' && /\/btw(?:\/stream)?$/.test(request.path),
);
expect(calls).toHaveLength(1);
expect(calls[0]?.path).toMatch(/\/btw$/);
});
test('does not retry legacy JSON after an advertised streaming failure', async ({
page,
}, testInfo) => {
await openSession(page, testInfo);
const paths: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (request.method() === 'POST' && /\/btw(?:\/stream)?$/.test(path))
paths.push(path);
});
await page.route('**/btw/stream', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Unsupported stream' }),
}),
);
await page.locator(EDITOR).fill('/btw Explain this');
await page.locator(EDITOR).press('Enter');
await expect(page.getByText(/Side question failed/).first()).toBeVisible();
expect(paths).toHaveLength(1);
expect(paths[0]).toMatch(/\/btw\/stream$/);
});
test('follows BTW output until scrolled up, resumes at bottom, and resets for a new question', async ({
page,
}, testInfo) => {
await openSession(page, testInfo);
const answer = (rows: number) =>
Array.from(
{ length: rows },
(_, index) => `Scroll row ${index}: streamed answer content.`,
).join('\n\n');
const release: Array<() => void> = [];
const gates = [0, 1, 2].map(
(index) =>
new Promise<void>((resolve) => {
release[index] = resolve;
}),
);
const requests: Array<{
requestId: string;
question: string;
resume?: boolean;
}> = [];
await page.route('**/btw/stream', async (route) => {
const body = route.request().postDataJSON() as {
requestId: string;
question: string;
resume?: boolean;
};
requests.push(body);
const fresh = body.question === 'New long question';
const attempt = requests.filter(
(request) => request.question === body.question,
).length;
if (!fresh && attempt > 1) await gates[attempt - 2];
const rows = fresh ? 40 : 20 + attempt * 20;
const event = {
v: 1,
type: fresh || attempt === 4 ? 'done' : 'answer',
requestId: body.requestId,
answer: answer(rows),
};
await route.fulfill({
contentType: 'text/event-stream',
body: `data: ${JSON.stringify(event)}\n\n`,
});
});
await page.locator(EDITOR).fill('/btw Long question');
await page.locator(EDITOR).press('Enter');
const viewport = page.locator('[data-web-shell-btw-content]');
const metrics = () =>
viewport.evaluate((element) => ({
top: element.scrollTop,
gap: element.scrollHeight - element.clientHeight - element.scrollTop,
}));
await expect(
page.getByText('Scroll row 39: streamed answer content.', { exact: true }),
).toBeAttached();
await expect.poll(async () => (await metrics()).gap).toBeLessThan(3);
await viewport.hover();
await page.mouse.wheel(0, -200);
await expect.poll(async () => (await metrics()).gap).toBeGreaterThan(100);
const paused = await metrics();
release[0]?.();
await expect(
page.getByText('Scroll row 59: streamed answer content.', { exact: true }),
).toBeAttached();
await page.screenshot({
path: testInfo.outputPath('btw-scroll-paused.png'),
animations: 'disabled',
});
expect(Math.abs((await metrics()).top - paused.top)).toBeLessThan(3);
await viewport.hover();
await page.mouse.wheel(0, 100_000);
await expect.poll(async () => (await metrics()).gap).toBeLessThan(3);
release[1]?.();
await expect(
page.getByText('Scroll row 79: streamed answer content.', { exact: true }),
).toBeAttached();
await expect.poll(async () => (await metrics()).gap).toBeLessThan(3);
release[2]?.();
await expect(
page.getByText('Scroll row 99: streamed answer content.', { exact: true }),
).toBeAttached();
await expect.poll(async () => (await metrics()).gap).toBeLessThan(3);
expect(
requests
.slice(1)
.every(
(request) =>
request.requestId === requests[0]?.requestId &&
request.resume === true,
),
).toBe(true);
await page.screenshot({
path: testInfo.outputPath('btw-scroll-following.png'),
animations: 'disabled',
});
await viewport.hover();
await page.mouse.wheel(0, -200);
await expect.poll(async () => (await metrics()).gap).toBeGreaterThan(100);
await page.locator(EDITOR).fill('/btw New long question');
await page.locator(EDITOR).press('Enter');
await expect.poll(() => requests.at(-1)?.question).toBe('New long question');
await expect(viewport).toContainText('New long question');
await expect(
page.getByText('Scroll row 39: streamed answer content.', { exact: true }),
).toBeAttached();
await expect.poll(async () => (await metrics()).gap).toBeLessThan(3);
expect(requests.at(-1)?.requestId).not.toBe(requests[0]?.requestId);
});
test('keeps a partial BTW answer and shows the terminal stream error', async ({
page,
}, testInfo) => {
const daemon = await openSession(page, testInfo);
let finish: (() => void) | undefined;
const failure = new Promise<void>((resolve) => {
finish = resolve;
});
const requests: Array<{ requestId: string; resume?: boolean }> = [];
await page.route('**/btw/stream', async (route) => {
const body = route.request().postDataJSON() as {
requestId: string;
resume?: boolean;
};
requests.push(body);
if (requests.length > 1) await failure;
const event =
requests.length === 1
? {
v: 1,
type: 'answer',
requestId: body.requestId,
answer: 'Received before the failure.',
}
: {
v: 1,
type: 'error',
requestId: body.requestId,
code: 'btw_timeout',
message: 'Side question timed out',
};
await route.fulfill({
contentType: 'text/event-stream',
body: `data: ${JSON.stringify(event)}\n\n`,
});
});
await page.locator(EDITOR).fill('/btw Partial failure');
await page.locator(EDITOR).press('Enter');
await expect(
page.getByText('Received before the failure.', { exact: true }),
).toBeVisible();
await expect.poll(() => requests.length).toBe(2);
expect(requests[1]).toEqual({ ...requests[0], resume: true });
finish?.();
await expect(page.getByText(/Side question timed out/).first()).toBeVisible();
await expect(
page.getByText('Received before the failure.', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Press Space, Enter, or Escape to dismiss', { exact: true }),
).toBeVisible();
expect(
daemon.requests.filter(
(request) => request.method === 'POST' && /\/btw$/.test(request.path),
),
).toHaveLength(0);
await page.screenshot({
path: testInfo.outputPath('btw-partial-error.png'),
animations: 'disabled',
});
});
test('cancels the pending BTW stream with Escape without cancelling the main task', async ({
page,
}, testInfo) => {
const daemon = await openSession(page, testInfo);
await page.locator(EDITOR).fill('Continue the main task');
await page.locator(EDITOR).press('Enter');
await expect.poll(() => daemon.promptRequests().length).toBe(1);
const mainCancel = page.getByRole('button', {
name: 'esc to cancel',
exact: true,
});
await expect(mainCancel).toBeVisible();
let requestId: string | undefined;
await page.route('**/btw/stream', async (route) => {
const body = route.request().postDataJSON() as { requestId: string };
requestId = body.requestId;
await route.fulfill({
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ v: 1, type: 'answer', requestId, answer: 'Pending cancellable answer.' })}\n\n`,
});
});
await page.locator(EDITOR).fill('/btw Cancel this question');
await page.locator(EDITOR).press('Enter');
await expect(
page.getByText('Pending cancellable answer.', { exact: true }),
).toBeVisible();
const cancelled = page.waitForRequest(
(request) =>
request.method() === 'DELETE' &&
new URL(request.url()).pathname.endsWith(`/btw/stream/${requestId}`),
);
await page.keyboard.press('Escape');
await cancelled;
await expect(page.locator('[data-web-shell-btw-content]')).toHaveCount(0);
await expect(mainCancel).toBeVisible();
expect(daemon.promptRequests()).toHaveLength(1);
expect(
daemon.requests.filter(
(request) =>
request.method === 'POST' &&
(/\/btw$/.test(request.path) || /\/cancel$/.test(request.path)),
),
).toHaveLength(0);
});