mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): stream resumable BTW answers (#15709)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
6ec7e8765e
commit
6f53133d6d
45 files changed
+3949
-233
No files matched your search
@@ -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 重启恢复以及超过缓存保留期的长期离线恢复不在本次范围内。
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
() =>
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -2465,6 +2465,7 @@ function currentServeFeaturesForRunQwenServe(
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
sessionGenerationAvailable: true,
|
||||
sessionBtwStreamAvailable: true,
|
||||
currentSessionSchedulingAvailable,
|
||||
workspaceGenerationAvailable: true,
|
||||
rateLimit: opts.rateLimit === true,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }] }] },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) }),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user