* fix(subagent): 子代理页实时预览改为批量接口,避免 O(N²) history 请求风暴
## 背景
DSH Web UI 中,当主代理同时运行多个子代理时,`dsh-better-sidebar` 的「任务管理/子代理」页面会出现明显卡顿。经调查,卡顿根因不在页面渲染,而在实时预览的请求模式。
## 根因
旧实现中,每个 running 子代理卡片都独立轮询 `subagents.history`:
- N 个 running 子代理 → 每 3 秒产生 N 次 `subagents.history` 请求;
- 每次 `subagents.history` 都会在 host 侧触发一次完整的 `listChildren` 全量子代理目录枚举;
- 子代理数量越多,服务端重复扫描越严重,近似 O(N²) 放大;
- 请求超过 3 秒时,客户端还会 abort 旧请求并重发,进一步形成请求风暴。
## 改动内容
1. **新增 host 批量接口 `POST /sidebar/api/subagents.live`**
- 请求 `{ rootSessionId }`;
- 通过 `ctx.subagents.listDescendants(rootSessionId)` 一次枚举整棵子代理树;
- 仅对 running 且非 Side Chat 的子代理,从 session events 尾部提取最新 `text/tool` 活动;
- 返回 `{ live: Record<childId, { text?, tool? }> }`;
- 缺 `subagents`/`agents` 服务或枚举失败时返回 503;单个子代理日志异常只跳过该子代理。
2. **客户端改为单轮询 + 单飞行请求**
- 删除每个 running 子代理独立的 `subagents.history` 定时器;
- 在 `SubagentView` 中合并为一个 `useSubagentLive` 轮询 hook;
- 使用递归 `setTimeout` 和单在途请求,慢请求不再被 abort/重发;
- 页面隐藏或 root 变化时停止轮询并中止在途请求。
3. **解析器优化**
- `lastActivity` 改为接收原始 `SidebarSessionEvent[]`;
- 从事件尾部向前扫描,找到最新 text 和 tool 后提前停止,避免复制/遍历完整历史。
4. **UI 保持不变**
- 实时预览的展示逻辑、文案、截断规则均未改变。
## 测试
- 新增 `tests/subagent-live-route.spec.ts`:覆盖批量接口成功聚合、只返回 running、跳过 Side Chat、缺服务 503、枚举失败 503、`ctx.get` 回退、bad-request。
- 新增 `tests/subagent-live-polling.spec.tsx`:覆盖每轮只发 1 次 `subagents.live`、不再调用 `subagents.history`、单在途不并发。
- 更新 `tests/subagent-activity.spec.ts` 与 `tests/subagent-jobs-view.spec.tsx`。
- `pnpm typecheck` ✅
- `pnpm build` ✅
- 相关测试 29 个全部通过 ✅
> 本机完整 `pnpm test` 仍有与本改动无关的环境性失败(node-pty 超时、Windows CRLF 断言、pty-deps 路径断言),不影响本 PR 逻辑。
* fix(subagent): live 预览以 catalog activity 为准,删除 agents 第二事实源 (#298)
- host 路由 running 门改回 entry.activity === 'running'(与客户端卡片渲染门一致),
恢复 PR 承诺的 UI 不变:turn 间 idle 的 continuable 子代理仍显示最后活动
- 删除 ctx.agents 依赖与 SidebarAgent.status 新增(agents 缺失时不再静默 200 空 map;
503 契约收窄为只涉及 subagents,与文档/测试一致)
- service 解析统一为 ctx.get('subagents')(兄弟路由惯例;插件未 inject subagents,
直接属性访问有抛错风险)
- buildSubagentLiveApi 签名改收完整 Context,删除 SidebarSubagentLiveContext 窄接口
- api.ts 复用 LastActivity,删除重复类型 SubagentLiveEntry
- 测试:route spec 删除 agents 相关用例;polling spec 新增页面隐藏停止轮询、
root 变化清空 live 并重启轮询两个用例
* fix(subagent): live 预览恢复最近 12 条消息窗口,排除陈旧活动 (#298)
CR 反馈:新批量接口把完整 session event log 喂给 lastActivity,
- 长时间只输出文本、早期调用过工具的 running 子代理会持续显示陈旧 tool
- 无 tool 的长会话每次轮询反向扫描整个事件数组,重新引入与历史长度
相关的 CPU 成本
修复(与旧 subagents.history({ maxMessages: 12 }) 窗口语义一致):
- lastActivity 增加可选 maxMessages 参数:反向扫描按表面消息
(user/message + assistant/message)计数,窗口满后更老事件一律跳过
- 路由传 LIVE_WINDOW_MESSAGES = 12(导出常量)
- 回归测试:窗口外旧 tool 不返回;窗口内 tool 保留;非消息事件不消耗配额
---------
Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
324 lines
15 KiB
TypeScript
324 lines
15 KiB
TypeScript
/**
|
|
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
|
|
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
|
|
* cwd from the client's own list summary. The host prefers its attached
|
|
* session header and uses the summary cwd only while the session is still
|
|
* hydrating at page load (a detached session would otherwise fail the
|
|
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
|
*/
|
|
import { encodeHtmlUrl } from '../html-route.ts'
|
|
import type { LastActivity } from '../subagent-activity.ts'
|
|
import type { SidechatThreadInfo } from '../sidechat-core.ts'
|
|
import type { BrowserProbeResult } from './browser.ts'
|
|
|
|
/** One wire failure. */
|
|
export class SidebarApiError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message)
|
|
}
|
|
}
|
|
|
|
/** Explorer row (host fs-tree shape). */
|
|
export interface FsEntry {
|
|
name: string
|
|
path: string
|
|
isDir: boolean
|
|
hidden: boolean
|
|
/** Whether the row is a symlink; `isDir` then describes the link's target. */
|
|
isSymlink: boolean
|
|
/** For symlinks: the target is missing or unreadable (stat failed). */
|
|
broken: boolean
|
|
}
|
|
|
|
/** Git status entry (host git shape). */
|
|
export interface GitStatusEntry {
|
|
path: string
|
|
xy: string
|
|
}
|
|
|
|
/** Git status snapshot. */
|
|
export interface GitStatusResult {
|
|
isRepo: boolean
|
|
branch?: string
|
|
entries: GitStatusEntry[]
|
|
}
|
|
|
|
/** One git log row. */
|
|
export interface GitLogEntry {
|
|
/** Short hash (7+ chars, display). */
|
|
hash: string
|
|
/** Full 40-char hash (advanced operations). */
|
|
hashFull: string
|
|
subject: string
|
|
author: string
|
|
/** ISO 8601 author date (`%ai`). */
|
|
date: string
|
|
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
|
refs: string
|
|
}
|
|
|
|
/** Text read result. */
|
|
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
|
|
/** Binary read result (no content; images load through the media route).
|
|
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
|
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
|
|
|
/**
|
|
* One jobs.output response: the output the MODEL has read so far for the
|
|
* job (replayed from the owner session's event log — the model's
|
|
* job_output cursor is never touched, so the pane can never steal the
|
|
* agent's bytes). `read` is false until the model actually called
|
|
* job_output for the job.
|
|
*/
|
|
export interface JobOutputResult {
|
|
text: string
|
|
/** True when the host capped the text at its output limit. */
|
|
truncated: boolean
|
|
/** Whether the model has read the job at least once. */
|
|
read: boolean
|
|
}
|
|
|
|
/** The `subagents.live` response: running child id → latest activity. */
|
|
export type SubagentLiveResult = { live: Record<string, LastActivity> }
|
|
|
|
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
|
|
export type TerminalDepsStatus =
|
|
| { ok: true }
|
|
| {
|
|
ok: false
|
|
/** The require-time error message (module missing, native binding broken…). */
|
|
cause: string
|
|
/** The pasteable repair command (terminal/cmd). */
|
|
command: string
|
|
/** The detected profile name (null when undetected → the command defaults to web). */
|
|
profile: string | null
|
|
/** Optional supplementary hint (fallback command only). */
|
|
note?: string
|
|
}
|
|
|
|
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
|
let response: Response
|
|
try {
|
|
response = await fetch(`/sidebar/api/${method}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
})
|
|
} catch (error) {
|
|
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
|
}
|
|
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
|
= await response.json().catch(() => null)
|
|
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
|
throw new SidebarApiError(
|
|
parsed?.error?.code ?? 'http',
|
|
parsed?.error?.message ?? `HTTP ${response.status}`,
|
|
)
|
|
}
|
|
return parsed.value as T
|
|
}
|
|
|
|
/**
|
|
* Upload one file to the sidebar's raw upload route: the File goes straight
|
|
* into the POST body (no JSON/base64 re-encoding — the host streams it into
|
|
* the workspace). Failure surfaces as {@link SidebarApiError} with the wire
|
|
* code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
|
|
* with the DOMException as-is (the caller decides whether that is an error).
|
|
*/
|
|
async function fetchUpload<T>(
|
|
scope: SessionScope,
|
|
dir: string,
|
|
relativePath: string,
|
|
body: Blob,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
|
|
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
let response: Response
|
|
try {
|
|
response = await fetch(`/sidebar/upload?${params.toString()}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/octet-stream' },
|
|
body,
|
|
signal,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
|
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
|
}
|
|
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
|
= await response.json().catch(() => null)
|
|
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
|
throw new SidebarApiError(
|
|
parsed?.error?.code ?? 'http',
|
|
parsed?.error?.message ?? `HTTP ${response.status}`,
|
|
)
|
|
}
|
|
return parsed.value as T
|
|
}
|
|
|
|
/** One request's session scope: the conversation id plus its cwd when known. */
|
|
export interface SessionScope {
|
|
sessionId: string
|
|
/** The session's working directory from the client list summary (optional). */
|
|
cwd?: string
|
|
}
|
|
|
|
/** Fold a scope into a JSON payload ({cwd} only when present). */
|
|
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
|
|
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
|
}
|
|
|
|
/** The sidebar API surface (session scope threaded through every call). */
|
|
export const api = {
|
|
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
|
|
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
|
|
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
|
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
|
|
/** Global recursive file-name search rooted at the session cwd (the editor
|
|
* side panel's search box); matches are cwd-relative '/'-separated paths. */
|
|
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
|
|
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
|
|
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
|
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
|
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
|
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
|
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
|
* `relativePath`); the host streams it under the session workspace. */
|
|
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
|
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
|
|
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
|
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
|
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
|
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
|
gitStage: (scope: SessionScope, path?: string) =>
|
|
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
|
gitUnstage: (scope: SessionScope, path?: string) =>
|
|
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
|
gitCommit: (scope: SessionScope, message: string) =>
|
|
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
|
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
|
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
|
gitCheckout: (scope: SessionScope, branch: string) =>
|
|
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
|
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
|
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
|
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
|
...(count !== undefined ? { count } : {}),
|
|
...(skip !== undefined ? { skip } : {}),
|
|
}), signal),
|
|
/** Full patch text of one commit (diff display for the history rows). */
|
|
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
|
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
|
/** Discard the worktree changes of one file (the index is untouched). */
|
|
gitDiscard: (scope: SessionScope, path: string) =>
|
|
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
|
/** Revert one commit onto the current branch. */
|
|
gitRevert: (scope: SessionScope, hash: string) =>
|
|
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
|
/** Cherry-pick one commit onto the current branch. */
|
|
gitCherryPick: (scope: SessionScope, hash: string) =>
|
|
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
|
/** Release a terminal's process immediately (tab closed; the WS close frame
|
|
* may be unreachable while the socket is down, so the host also accepts
|
|
* this explicit route). */
|
|
ptyClose: (scope: SessionScope, tab: string) =>
|
|
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
|
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
|
agentPtyClose: (uuid: string) =>
|
|
call<{ ok: true }>('agent-pty.close', { uuid }),
|
|
/** Terminal dependency status (issue #140): after a WS close 1011 with
|
|
* reason `pty-deps-missing` the view fetches the full repair details here
|
|
* (the close reason itself is capped at 123 bytes). */
|
|
terminalDeps: () =>
|
|
call<TerminalDepsStatus>('terminal.deps', {}),
|
|
/**
|
|
* The output the model has read so far for one background job (replayed
|
|
* from the owner session's event log — never the model's job_output
|
|
* cursor). The scope MUST be the job's OWNER session.
|
|
*/
|
|
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
|
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
|
|
/** Request cancellation of one background job (live jobs flip to stopping). */
|
|
jobKill: (scope: SessionScope, id: string, reason?: string) =>
|
|
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
|
|
id,
|
|
...(reason !== undefined ? { reason } : {}),
|
|
})),
|
|
/**
|
|
* One batch live-preview fetch for the whole Subagent tree. The payload is
|
|
* the already-resolved topology ROOT (not a session scope); the host
|
|
* enumerates descendants once and folds running children's activity.
|
|
*/
|
|
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
|
|
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
|
|
/** Create a Side Chat thread: a child session seeded with the parent's
|
|
* full log up to now. Empty question = immediate create (Codex-style):
|
|
* the thread opens empty, the first prompt carries the boundary. */
|
|
sidechatStart: (sessionId: string, question?: string) =>
|
|
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
|
|
/** Deliver one follow-up message to a Side Chat thread. */
|
|
sidechatPrompt: (childId: string, text: string) =>
|
|
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
|
|
/** Abort a Side Chat thread's running turn (queued work is preserved). */
|
|
sidechatCancel: (childId: string) =>
|
|
call<{ accepted: true }>('sidechat.cancel', { childId }),
|
|
/** Release a Side Chat thread's live agent (history stays persisted). */
|
|
sidechatDispose: (childId: string) =>
|
|
call<{ accepted: true }>('sidechat.dispose', { childId }),
|
|
/** Live state + agent identity (provider/model/preset) of a thread. */
|
|
sidechatInfo: (childId: string) =>
|
|
call<SidechatThreadInfo>('sidechat.info', { childId }),
|
|
/** The effective terminal shell and its display name (plugin-global). */
|
|
shellGet: () =>
|
|
call<{ shell: string; name: string }>('shell.get', {}),
|
|
/** Read the side card preferences (plugin-global, no session scope). */
|
|
settingsGet: () =>
|
|
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
|
|
/** Merge a patch into the side card preferences (revision-guarded). */
|
|
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
|
call<{ value?: unknown; revision?: number }>('settings.update', {
|
|
patch,
|
|
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
|
}),
|
|
/** Probe a URL's response headers (the sidebar browser's embeddability
|
|
* check; see the host's browser.probe route). */
|
|
browserProbe: (url: string, signal?: AbortSignal) =>
|
|
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
|
}
|
|
|
|
/** Absolute URL of the media route for one path (images only). */
|
|
export function mediaUrl(scope: SessionScope, path: string): string {
|
|
return fileUrl(scope, path, false)
|
|
}
|
|
|
|
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
|
|
* `Content-Disposition: attachment`, so the browser saves the file. */
|
|
export function downloadUrl(scope: SessionScope, path: string): string {
|
|
return fileUrl(scope, path, true)
|
|
}
|
|
|
|
/** Shared URL builder for the /sidebar/file route (media vs download). */
|
|
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
|
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
|
|
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
if (download) params.set('download', '1')
|
|
return `/sidebar/file?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Absolute URL of the HTML preview route (see html-route.ts): the path is
|
|
* fully encoded so the previewed page's relative assets resolve back into
|
|
* the same route with the session scope intact. The UNC marker is
|
|
* platform-neutral — the host's requireAbsolute resolves the decoded
|
|
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
|
|
* client-side platform signal is needed.
|
|
*/
|
|
export function htmlUrl(scope: SessionScope, path: string): string {
|
|
return encodeHtmlUrl(scope.sessionId, path)
|
|
}
|