refactor(tasks): 移植主 Agent 同款合并活动折叠(数据层)
把上一轮验证过的数据层搬到工作图基座上,图/树两种模式共用:
- 新增 src/process-activity.ts:宿主 ui-chat 的 process group 算法移植
(activity() 分类表逐条照抄、liveToolDetail 键优先级与 160 字截断、
counts 计数排序与并列规则、preparing 语义、按 user/reply 边界切范围)。
按本轮设计裁剪掉逐阶段列表(阶段改由徽标 + 颜色表达)。新增一条
偏差:最新范围为空(子代理刚回复完)时回退到上一个「做过事」的范围,
否则已结算卡片的小条只会显示「已完成分析」。
- chatT:从宿主 chat 命名空间读 message.stepProcess.* 措辞(方法调用 +
try/catch 兜底,key 未命中即回退),4 个回归用例钉住「不许解绑 bind」。
- subagents.live 载荷换新:SidebarChildLiveView { running?, text?, summary?,
lastEventTime? },报告树内每个子节点(含已结算)与拓扑根;删除
expand/stages 机制;childActivity → childLive(「不在 map 里 = 未运行」
的读法随旧协议一起作废)。
- tasks-model 消费新形状;LiveLine 与节点详情浮窗改显示合并活动措辞。
- 删除 src/subagent-activity.ts 与 tests/subagent-activity.spec.ts。
验证:typecheck / lint 通过;pnpm test 1252 passed / 9 skipped。
(cherry picked from commit 5e5284f214846b0c366048acbee459f3a7a79456)
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarChildLiveView,
|
||||
SidebarJobView,
|
||||
SidebarSessionList,
|
||||
SidebarSubagentAddress,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
} from './subagent-detect.ts'
|
||||
import { subagentCatalogs } from './subagent-catalog.ts'
|
||||
import { treeSessionIds } from './subagent-lineage.ts'
|
||||
import { type LastActivity } from '../subagent-activity.ts'
|
||||
import { collectTreeJobs, orderJobs } from './subagent-jobs.ts'
|
||||
import { api, type TeamsViewResult } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
@@ -93,8 +93,8 @@ interface CatalogRefreshFace {
|
||||
function useSubagentLive(
|
||||
rootId: string | undefined,
|
||||
active: boolean,
|
||||
): Readonly<Record<string, LastActivity>> {
|
||||
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
||||
): Readonly<Record<string, SidebarChildLiveView>> {
|
||||
const [live, setLive] = useState<Record<string, SidebarChildLiveView>>({})
|
||||
|
||||
// A new tree must never inherit another root's live previews.
|
||||
useEffect(() => { setLive({}) }, [rootId])
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import {
|
||||
AgentGlyph, WorkflowGlyph, flatten, modeLabel, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
|
||||
} from './tasks-shared.tsx'
|
||||
import { liveActivityLabel } from './process-labels.ts'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
@@ -81,9 +82,9 @@ export function AgentNodePopover(props: {
|
||||
/** The row's mode word; `unknown` claims no mode and this row is omitted. */
|
||||
const nodeMode = modeLabel(node.mode)
|
||||
const liveText = node.live?.text !== undefined ? flatten(node.live.text) : undefined
|
||||
const liveTool = node.live?.tool !== undefined
|
||||
? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}`
|
||||
: undefined
|
||||
// The merged activity the main conversation would show for this node's
|
||||
// newest range (category wording + the running call's detail).
|
||||
const liveTool = liveActivityLabel(node.live?.summary)
|
||||
return (
|
||||
<div className={css.popCard}>
|
||||
<div className={css.popHead}>
|
||||
|
||||
+5
-4
@@ -7,10 +7,10 @@
|
||||
* 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 { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type {
|
||||
SidebarCreateTeamTaskRequest,
|
||||
SidebarChildLiveView,
|
||||
SidebarJobView,
|
||||
SidebarSessionEvent,
|
||||
SidebarTeamMemberView,
|
||||
@@ -119,8 +119,8 @@ export interface JobOutputResult {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** The `subagents.live` response: running child id → latest activity. */
|
||||
export type SubagentLiveResult = { live: Record<string, LastActivity> }
|
||||
/** The `subagents.live` response: one row per tree child (and the root). */
|
||||
export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
|
||||
|
||||
/** The `workflows.list` response: the tree's folded workflow runs. */
|
||||
export type WorkflowsListResult = { runs: WorkflowRunView[] }
|
||||
@@ -375,7 +375,8 @@ export const api = {
|
||||
/**
|
||||
* 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.
|
||||
* enumerates descendants once and folds every child's newest process range
|
||||
* into the main agent's merged-activity summary.
|
||||
*/
|
||||
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
|
||||
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
|
||||
|
||||
+52
-2
@@ -976,7 +976,17 @@ export const LOCALE_NS = 'betterSidebar'
|
||||
// path most sessions never enter.
|
||||
|
||||
/** The DSH locale service attached by the client apply (absent → browser detection). */
|
||||
let localeService: { getSnapshot(): { active: string } } | undefined
|
||||
let localeService: {
|
||||
getSnapshot(): { active: string }
|
||||
/**
|
||||
* Bind one registered namespace to a translate function (DSH's
|
||||
* `LocaleRuntime.bind`). Optional because a consumer may attach only the
|
||||
* snapshot face. The untyped overload is what makes cross-package key reads
|
||||
* possible: an UNREGISTERED namespace yields a function that returns the key
|
||||
* itself, which is exactly how {@link chatT} detects "not available".
|
||||
*/
|
||||
bind?(ns: string): (key: string, params?: Record<string, string | number>) => string
|
||||
} | undefined
|
||||
|
||||
/**
|
||||
* The better-locale override store attached by the client apply
|
||||
@@ -1003,10 +1013,50 @@ let betterLocaleStore: {
|
||||
* `t()` function, and the Sidebar root's locale subscription re-renders the
|
||||
* whole tree on switches.
|
||||
*/
|
||||
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
export function attachLocale(service: {
|
||||
getSnapshot(): { active: string }
|
||||
/** Optional namespace binding ({@link chatT} needs it; a bare snapshot face is still valid). */
|
||||
bind?(ns: string): (key: string, params?: Record<string, string | number>) => string
|
||||
} | undefined): void {
|
||||
localeService = service
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key in the HOST's own `chat` namespace — the wording the main
|
||||
* conversation's merged "process" header uses (`message.stepProcess.*`,
|
||||
* registered by `@deepseek-ai/dsh-client-ui-chat`). The Tasks page reads it so
|
||||
* a running node reads exactly like the main agent's own activity line instead
|
||||
* of inventing a second vocabulary (and without adding ~40 keys to 20 plugin
|
||||
* dictionaries).
|
||||
*
|
||||
* Deliberately untyped: these keys are NOT a documented host contract. A host
|
||||
* that renames one, or a deployment where the chat target never mounted,
|
||||
* returns the key unchanged — the caller then falls back to its own wording
|
||||
* ({@link chatT} answers undefined) rather than printing `message.…`.
|
||||
*
|
||||
* @param key - full `chat`-namespace key.
|
||||
* @param params - `{name}` placeholders to interpolate (the host does this).
|
||||
* @returns the translated text, or undefined when unavailable.
|
||||
*/
|
||||
export function chatT(key: string, params?: Record<string, string | number>): string | undefined {
|
||||
const service = localeService
|
||||
if (service === undefined) return undefined
|
||||
try {
|
||||
// MUST stay a METHOD call: DSH's `bind` reads `this.bound`, so a detached
|
||||
// reference (`const bind = service.bind; bind('chat')`) throws
|
||||
// "Cannot read properties of undefined (reading 'bound')" — caught by the
|
||||
// real-host mount lane, invisible to closure fakes in unit tests.
|
||||
const translate = service.bind?.('chat')
|
||||
if (typeof translate !== 'function') return undefined
|
||||
const text = translate(key, params)
|
||||
return typeof text === 'string' && text !== '' && text !== key ? text : undefined
|
||||
} catch {
|
||||
// The namespace seam is undocumented: a host that reshapes it must degrade
|
||||
// to the caller's own wording, never break a render.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or detach, with undefined) the better-locale override store.
|
||||
* When attached with an active override, `t()` consults the store's
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The two labels the Tasks page borrows from the main conversation's merged
|
||||
* activity display: the LIVE line ("正在运行命令 · npm run build") and the
|
||||
* DONE title for a settled range ("已读取文件、执行了命令").
|
||||
*
|
||||
* Both are ports of the host's own composition
|
||||
* (`dsh-client-ui-chat`'s `ProcessGroupHeader` + `step-process.js`
|
||||
* `processTitle`), reading the host's `chat` dictionary through
|
||||
* {@link chatT} so the wording — and every language the host ships — matches
|
||||
* what the main chat shows for the same work.
|
||||
*
|
||||
* The translator is injectable for tests; the default is the host bridge. When
|
||||
* a key does not resolve (a host that renamed it, or a deployment where the
|
||||
* chat target never mounted) the call answers undefined and the card falls
|
||||
* back to the tool detail it already has, never to a raw `message.…` key.
|
||||
*/
|
||||
import type { ProcessActivitySummary } from '../process-activity.ts'
|
||||
import { chatT } from './locales.ts'
|
||||
|
||||
/** A host-namespace translator; undefined means "this key is not available". */
|
||||
export type ChatTranslate = (key: string, params?: Record<string, string | number>) => string | undefined
|
||||
|
||||
/** The host's `' · '` line separator (`message.turnProcess.separator`). */
|
||||
function separator(t: ChatTranslate): string {
|
||||
return t('message.turnProcess.separator') ?? ' · '
|
||||
}
|
||||
|
||||
/**
|
||||
* The live line of one stage: the running category's `…ing` wording plus the
|
||||
* running call's detail, or the "thinking" default while nothing is dispatched
|
||||
* (exactly the host's `ProcessGroupHeader` label rule).
|
||||
*
|
||||
* @param summary - the newest stage's summary (absent while the child has none).
|
||||
* @param t - host translator (defaults to the `chat` namespace bridge).
|
||||
* @returns the line, or undefined when there is nothing to say.
|
||||
*/
|
||||
export function liveActivityLabel(
|
||||
summary: ProcessActivitySummary | undefined,
|
||||
t: ChatTranslate = chatT,
|
||||
): string | undefined {
|
||||
const activity = summary?.running ?? 'thinking'
|
||||
const preparing = summary?.preparing === true
|
||||
// The host maps its `thinking` fallback onto `prepare.tools` (there is no
|
||||
// `prepare.thinking` key).
|
||||
const label = preparing
|
||||
? t(`message.stepProcess.prepare.${activity === 'thinking' ? 'tools' : activity}`)
|
||||
: t(`message.stepProcess.${activity}`)
|
||||
if (label === undefined) {
|
||||
// No host wording for this category: the detail is the honest fallback.
|
||||
return summary?.runningDetail === undefined || summary.runningDetail === ''
|
||||
? undefined
|
||||
: summary.runningDetail
|
||||
}
|
||||
const detail = preparing ? '' : summary?.runningDetail ?? ''
|
||||
return detail === '' ? label : `${label}${separator(t)}${detail}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The last-resort activity text: the ranked categories spelled out as
|
||||
* `kind ×count`. It keeps a card informative when the host's `chat` wording is
|
||||
* unavailable (renamed keys, or a deployment where the chat target never
|
||||
* mounted) without inventing a second vocabulary for the normal case — the
|
||||
* category names are the host's own, and they are language-neutral.
|
||||
*
|
||||
* @param summary - a stage's summary.
|
||||
* @returns the counts text, or undefined when the stage called nothing.
|
||||
*/
|
||||
export function activityCountsText(
|
||||
summary: ProcessActivitySummary | undefined,
|
||||
): string | undefined {
|
||||
const counts = summary?.counts ?? []
|
||||
if (counts.length === 0) return undefined
|
||||
return counts.map(entry => `${entry.kind} ×${entry.count}`).join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The settled range's title: the top three categories' `done.*` wording joined
|
||||
* the way the host joins it (two labels share the "已" prefix; more than three
|
||||
* append the "等" suffix). Counts are never printed.
|
||||
*
|
||||
* @param summary - a stage's summary.
|
||||
* @param t - host translator (defaults to the `chat` namespace bridge).
|
||||
* @returns the title, or undefined when the host wording is unavailable.
|
||||
*/
|
||||
export function doneActivityTitle(
|
||||
summary: ProcessActivitySummary | undefined,
|
||||
t: ChatTranslate = chatT,
|
||||
): string | undefined {
|
||||
const counts = summary?.counts ?? []
|
||||
if (counts.length === 0) return t('message.stepProcess.done.thinking')
|
||||
const labels: string[] = []
|
||||
for (const { kind } of counts.slice(0, 3)) {
|
||||
const label = t(`message.stepProcess.done.${kind}`)
|
||||
if (label === undefined) return undefined
|
||||
labels.push(label)
|
||||
}
|
||||
const first = labels[0]
|
||||
if (first === undefined) return undefined
|
||||
const second = labels[1]
|
||||
if (second === undefined) return first
|
||||
// The host lowercases every label after the first for latin languages; a
|
||||
// Chinese label is unaffected by that.
|
||||
const continuation = (label: string): string => label.charAt(0).toLowerCase() + label.slice(1)
|
||||
if (labels.length === 2) {
|
||||
const prefix = t('message.stepProcess.sharedPrefix') ?? ''
|
||||
const stripped = prefix !== '' && first.startsWith(prefix) && second.startsWith(prefix)
|
||||
? second.slice(prefix.length)
|
||||
: second
|
||||
return t('message.stepProcess.joinTwo', { first, second: continuation(stripped) })
|
||||
?? `${first}${separator(t)}${continuation(stripped)}`
|
||||
}
|
||||
const joined = [first, ...labels.slice(1).map(continuation)].join(t('message.stepProcess.comma') ?? ', ')
|
||||
if (counts.length > 3) return t('message.stepProcess.more', { title: joined }) ?? joined
|
||||
return joined
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import type {
|
||||
SidebarSessionList,
|
||||
SidebarSubagentCatalogEntry,
|
||||
} from '../context-types.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidebarChildLiveView } from '../context-types.ts'
|
||||
|
||||
/** One parent's direct-child catalog, as the topology consumes it. */
|
||||
export interface SubagentCatalogView {
|
||||
@@ -89,16 +89,19 @@ export function isKnownLeaf(
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's live state. The `subagents.live` route folds only RUNNING
|
||||
* children (a Side Chat thread and an idle child are absent), so absence is
|
||||
* the inactive answer rather than missing data.
|
||||
* One child's live view, or undefined when the batch has not reported it yet
|
||||
* (the first poll of a freshly rooted tree).
|
||||
*
|
||||
* @param live - the batch live map (`child id → latest activity`).
|
||||
* The `subagents.live` route reports EVERY tree child with an explicit
|
||||
* `running` flag — the previous "absent means not running" reading cannot
|
||||
* survive a route that also reports settled children's summaries.
|
||||
*
|
||||
* @param live - the batch live map (`session id → live view`).
|
||||
* @param childSessionId - the child row being drawn.
|
||||
*/
|
||||
export function childActivity(
|
||||
live: Readonly<Record<string, LastActivity | undefined>>,
|
||||
export function childLive(
|
||||
live: Readonly<Record<string, SidebarChildLiveView | undefined>>,
|
||||
childSessionId: string,
|
||||
): 'running' | 'inactive' {
|
||||
return live[childSessionId] === undefined ? 'inactive' : 'running'
|
||||
): SidebarChildLiveView | undefined {
|
||||
return live[childSessionId]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* the core bundle; <100-node trees need no virtualisation).
|
||||
*/
|
||||
import type { TasksNode } from './tasks-model.ts'
|
||||
import type { SidebarChildLiveView } from '../context-types.ts'
|
||||
import { tasksEdges } from './tasks-model.ts'
|
||||
|
||||
/** Geometry constants of the canvas (px, pre-scale). The design target is the
|
||||
@@ -115,12 +116,18 @@ export interface LayoutOptions {
|
||||
* The tallest shape therefore is base + live + live-tail + task, which stays
|
||||
* inside {@link GRAPH_ROW_STRIDE} (rows can never overlap).
|
||||
*/
|
||||
/** Whether a node's live view carries a merged-activity row to render. */
|
||||
function hasMergedActivity(live: SidebarChildLiveView | undefined): boolean {
|
||||
if (live === undefined) return false
|
||||
return (live.summary?.counts.length ?? 0) > 0 || live.summary?.running !== undefined
|
||||
}
|
||||
|
||||
function nodeHeight(node: TasksNode): number {
|
||||
if (node.kind !== 'agent') return GRAPH_NODE_H
|
||||
let height = GRAPH_NODE_H
|
||||
if (node.state === 'running') {
|
||||
height += GRAPH_LIVE_H
|
||||
if (node.live?.tool !== undefined && node.live.text !== undefined) height += GRAPH_LIVE_TAIL_H
|
||||
if (hasMergedActivity(node.live) && node.live?.text !== undefined) height += GRAPH_LIVE_TAIL_H
|
||||
}
|
||||
if ((node.tasks?.length ?? 0) > 0) height += GRAPH_TASK_H
|
||||
return height
|
||||
|
||||
@@ -26,10 +26,10 @@ import type {
|
||||
SidebarTeamMemberView,
|
||||
SidebarTeamTaskView,
|
||||
} from '../context-types.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidebarChildLiveView } from '../context-types.ts'
|
||||
import type { WorkflowRunView } from '../workflow-runs.ts'
|
||||
import { isSideThreadSummary } from './subagent-detect.ts'
|
||||
import { childActivity, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts'
|
||||
import { childLive, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts'
|
||||
|
||||
/** Display state of one agent node (drives the dot + fold candidacy). */
|
||||
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
|
||||
@@ -59,8 +59,8 @@ export interface TasksAgentNode {
|
||||
activity: 'running' | 'inactive'
|
||||
/** The on-screen session (the "you are here" marker). */
|
||||
current: boolean
|
||||
/** Live tail of a running child (the icon + tool + args line). */
|
||||
live?: LastActivity
|
||||
/** Live tail of a running child (the merged activity + newest text). */
|
||||
live?: SidebarChildLiveView
|
||||
/** Team enrichment (membership view matched by session id). */
|
||||
team?: {
|
||||
role: 'lead' | 'teammate'
|
||||
@@ -108,7 +108,7 @@ export interface TasksModelInput {
|
||||
catalogs: Readonly<Record<string, SubagentCatalogView | undefined>>
|
||||
rootId: string
|
||||
currentSessionId: string
|
||||
live: Readonly<Record<string, LastActivity | undefined>>
|
||||
live: Readonly<Record<string, SidebarChildLiveView | undefined>>
|
||||
runs: readonly WorkflowRunView[]
|
||||
teamMembers: readonly SidebarTeamMemberView[]
|
||||
/** The team's shared tasks; each lands on its OWNER's node. */
|
||||
@@ -215,9 +215,10 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
if (entry.label?.startsWith('Side: ') ?? false) continue
|
||||
const team = teamOf(entry.id)
|
||||
// DSH 0.1.7's catalog row carries the identity only: the live channel
|
||||
// folds RUNNING children (absence = not running) and a row keeps its
|
||||
// disclosure unless its own catalog is known-empty.
|
||||
const activity = childActivity(live, entry.id)
|
||||
// reports EVERY child with an explicit `running` flag, and a row keeps
|
||||
// its disclosure unless its own catalog is known-empty.
|
||||
const view = childLive(live, entry.id)
|
||||
const activity: 'running' | 'inactive' = view?.running === true ? 'running' : 'inactive'
|
||||
const state: TasksNodeState = activity === 'running'
|
||||
? 'running'
|
||||
: team !== undefined ? teamState(team.status) : 'done'
|
||||
@@ -232,7 +233,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
state,
|
||||
activity,
|
||||
current: entry.id === currentSessionId,
|
||||
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
|
||||
...(view !== undefined ? { live: view } : {}),
|
||||
...(team !== undefined ? { team } : {}),
|
||||
...(tasksByNode.get(entry.id) !== undefined ? { tasks: tasksByNode.get(entry.id) } : {}),
|
||||
hasChildren: !isKnownLeaf(catalogs, entry.id),
|
||||
|
||||
+12
-10
@@ -16,7 +16,8 @@ import {
|
||||
IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconUserOutlineRegular,
|
||||
type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidebarChildLiveView } from '../context-types.ts'
|
||||
import { liveActivityLabel } from './process-labels.ts'
|
||||
import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { toolGlyph } from './tool-icons.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
@@ -189,25 +190,26 @@ export function TaskLine(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The live activity row of a RUNNING agent: the tool's own icon + tool name +
|
||||
* args (the approved format), plus the flattened last text line underneath.
|
||||
* The live activity row of a RUNNING agent: the merged process summary the
|
||||
* main conversation would show for the child's newest range (category wording
|
||||
* + the running call's detail), plus the flattened last text line underneath.
|
||||
* A running node with neither reads as thinking.
|
||||
*/
|
||||
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
|
||||
export function LiveLine(props: { live: SidebarChildLiveView | undefined }): ReactNode {
|
||||
const { live } = props
|
||||
if (live?.text === undefined && live?.tool === undefined) {
|
||||
const activity = liveActivityLabel(live?.summary)
|
||||
if (activity === undefined && live?.text === undefined) {
|
||||
return <span className={css.nodeMeta}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{live.tool !== undefined && (
|
||||
{activity !== undefined && (
|
||||
<span className={css.live}>
|
||||
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(9)}</span>
|
||||
<span className={css.liveTool}>{live.tool.name}</span>
|
||||
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>}
|
||||
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph('bash')(9)}</span>
|
||||
<span className={css.liveTool} title={activity}>{activity}</span>
|
||||
</span>
|
||||
)}
|
||||
{live.text !== undefined && (
|
||||
{live?.text !== undefined && (
|
||||
<span className={css.liveText}>{preview(flatten(live.text), TEXT_PREVIEW)}</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import type { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { BetterSidebarService } from './client/service.ts'
|
||||
import type { ProcessActivitySummary } from './process-activity.ts'
|
||||
|
||||
/** The request face route handlers see (structural subset of node's
|
||||
* IncomingMessage: the URL/method/header reads and the async body
|
||||
@@ -173,6 +174,31 @@ export interface SidebarHistoryEntry {
|
||||
/** Lifecycle status set of one background job (closed wire union). */
|
||||
export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* One tree child's live view as the `subagents.live` route reports it: the
|
||||
* catalog's activity flag plus the fold of the child's newest process range
|
||||
* (see ./process-activity.ts).
|
||||
*
|
||||
* `running` is the catalog's flag and is therefore always present on a CHILD
|
||||
* row; the fold's fields are optional. The route used to report running
|
||||
* children only, so "absent from the map" meant "not running" — a reading that
|
||||
* cannot survive a route which also reports settled children's summaries.
|
||||
*/
|
||||
export interface SidebarChildLiveView {
|
||||
/**
|
||||
* The catalog's activity flag. Present on every CHILD row; ABSENT on the
|
||||
* topology root, which the host cannot classify (the caller already knows
|
||||
* its session's running state from the session list).
|
||||
*/
|
||||
running?: boolean
|
||||
/** The child's newest assistant text (the card's detail line). */
|
||||
text?: string
|
||||
/** The newest range's merged activity; absent when that range called nothing. */
|
||||
summary?: ProcessActivitySummary
|
||||
/** Epoch ms of the newest event observed. */
|
||||
lastEventTime?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One background job as the client mirror sees it (wire `JobView` shape:
|
||||
* id/kind/label/status/detail?/startedAt/finishedAt?).
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* The plugin's port of the MAIN AGENT's merged "process" display: the compact
|
||||
* activity summary DSH's chat shows for a range of tool calls.
|
||||
*
|
||||
* The host computes that summary over materialized Chat nodes inside
|
||||
* `@deepseek-ai/dsh-client-ui-chat` (`conversation-nodes/process-activity.js`
|
||||
* → `activity()` / `liveToolDetail()` / `processActivity()`, rendered by
|
||||
* `ProcessGroupHeader`). None of it is exported — the client bundle's only
|
||||
* runtime exports are `apply`/`inject`/`isRunningTool`/`isSettledTool` — so a
|
||||
* surface that has only a SESSION EVENT LOG (a subagent's, which the chat view
|
||||
* never materializes) must reproduce the algorithm. This module is that
|
||||
* reproduction, kept framework-free so the host route and the node test
|
||||
* environment share one implementation.
|
||||
*
|
||||
* Faithful points, all deliberate:
|
||||
*
|
||||
* - the tool NAME → category map is copied verbatim (including the
|
||||
* `terminal_` prefix rule and the `*_inspect` suffix rule);
|
||||
* - `counts` ranks by distinct-call count descending, ties broken by first
|
||||
* appearance (the host relies on `Array.sort` stability + Map insertion
|
||||
* order);
|
||||
* - the running tool is the LAST unsettled call, and a "preparing" call (one
|
||||
* the model announced but never dispatched) counts and can be the running
|
||||
* one, exactly like the host's `PreparingToolCall` branch;
|
||||
* - `runningDetail` walks the host's key list in order and truncates the same
|
||||
* way;
|
||||
* - the range is cut where the host's process GROUPS are cut: at a
|
||||
* `user/message` and at every assistant message that carries text
|
||||
* (`TurnGroups.rebuild`'s `INDEPENDENT` set plus its `reply(node)` flush).
|
||||
*
|
||||
* Deliberate deviations, all forced by the input:
|
||||
*
|
||||
* - the host's `runningDetail` falls back to the last REASONING paragraph of a
|
||||
* running assistant step; a durable log carries no partial step, so the
|
||||
* fallback is omitted (the label then reads as the "thinking" default);
|
||||
* - the host reads `arguments` only for a dispatched call; a durable
|
||||
* `assistant/message` already carries the announced block's `arguments`, but
|
||||
* the preparing detail still follows the host (no detail at all) so both
|
||||
* sides read the same;
|
||||
* - an unsettled call at the tail of a SETTLED session is reported as not
|
||||
* running ({@link foldProcess}'s `live: false`), mirroring the host's
|
||||
* `closed` groups (`summary = { counts, running: undefined, runningDetail: '' }`);
|
||||
* - when the NEWEST range holds no call at all (the child just replied), the
|
||||
* fold reports the previous range instead — a card whose bar read "已完成分析"
|
||||
* because the last thing that happened was a sentence would tell the reader
|
||||
* nothing. The fallback range is never reported as running.
|
||||
*
|
||||
* This module deliberately does NOT build a per-STAGE list: the Tasks page
|
||||
* marks workflow phases with a badge + colour instead (see tasks-model.ts), so
|
||||
* only the current range's summary is derived here.
|
||||
*/
|
||||
import type { SidebarSessionEvent } from './context-types.ts'
|
||||
|
||||
/** The host's `ProcessActivity` union, verbatim. */
|
||||
export type ProcessActivity =
|
||||
| 'read' | 'readImage' | 'search' | 'write' | 'edit' | 'commands' | 'code'
|
||||
| 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools'
|
||||
|
||||
/**
|
||||
* The host's `ProcessActivitySummary` shape: the ranked work of one range plus
|
||||
* the live task detail. `preparing` exists only while the selected live
|
||||
* activity has not reached its `tool/call` event.
|
||||
*/
|
||||
export interface ProcessActivitySummary {
|
||||
counts: { kind: ProcessActivity; count: number }[]
|
||||
running?: ProcessActivity
|
||||
runningDetail: string
|
||||
preparing?: true
|
||||
}
|
||||
|
||||
/** One fold result: the newest range's summary plus the newest text line. */
|
||||
export interface ProcessFold {
|
||||
/** What a node card's status bar shows. */
|
||||
current: ProcessActivitySummary
|
||||
/** The newest assembled assistant text in the window (the card's detail line). */
|
||||
text?: string
|
||||
/** Epoch ms of the newest event in the window. */
|
||||
lastEventTime?: number
|
||||
}
|
||||
|
||||
/** How many events one fold may scan backwards (bounds the cost per child). */
|
||||
export const PROCESS_WINDOW_EVENTS = 400
|
||||
/** Detail cap, mirroring the host's `LIVE_TOOL_DETAIL_MAX_CHARS`. */
|
||||
export const PROCESS_DETAIL_MAX_CHARS = 160
|
||||
/** Cap of the detail text line sent to the client. */
|
||||
export const PROCESS_TEXT_MAX_CHARS = 400
|
||||
|
||||
/**
|
||||
* The host's category map, verbatim (the arms are mutually exclusive, so the
|
||||
* order only mirrors the host's for review).
|
||||
* @param name - durable tool-call name.
|
||||
* @returns the category the main agent's process group counts it under.
|
||||
*/
|
||||
export function activityOf(name: string): ProcessActivity {
|
||||
if (name === 'read') return 'read'
|
||||
if (name === 'read_image') return 'readImage'
|
||||
if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search'
|
||||
if (name === 'write') return 'write'
|
||||
if (name === 'edit' || name === 'apply_patch') return 'edit'
|
||||
if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) {
|
||||
return 'commands'
|
||||
}
|
||||
if (name === 'run_code') return 'code'
|
||||
if (name === 'web_search') return 'webSearch'
|
||||
if (name === 'web_fetch') return 'webFetch'
|
||||
if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents'
|
||||
if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan'
|
||||
if (name === 'ask_user_question' || name === 'request_user_input') return 'questions'
|
||||
return 'tools'
|
||||
}
|
||||
|
||||
/** The host's live-detail key priority, verbatim. */
|
||||
const LIVE_TOOL_DETAIL_KEYS = [
|
||||
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question',
|
||||
'questions', 'prompt', 'message', 'command', 'cmd', 'queries', 'query',
|
||||
'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
|
||||
] as const
|
||||
|
||||
/** Grapheme segmenter when the runtime has one (the host relies on `Intl.Segmenter`). */
|
||||
const SEGMENTER = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: undefined
|
||||
|
||||
/** Split into user-perceived characters, falling back to code points. */
|
||||
function graphemes(text: string): string[] {
|
||||
if (SEGMENTER === undefined) return Array.from(text)
|
||||
return Array.from(SEGMENTER.segment(text), part => part.segment)
|
||||
}
|
||||
|
||||
/** Collapse whitespace and truncate on a grapheme boundary (the host's normalize). */
|
||||
function normalizeDetail(value: unknown): string {
|
||||
const flat = (typeof value === 'string'
|
||||
? value
|
||||
: Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
? value.join(', ')
|
||||
: '').replace(/\s+/g, ' ').trim()
|
||||
const chars = graphemes(flat)
|
||||
if (chars.length <= PROCESS_DETAIL_MAX_CHARS) return flat
|
||||
return `${chars.slice(0, PROCESS_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
}
|
||||
|
||||
/** The first `question` string of an `ask_user_question` argument list. */
|
||||
function questionDetail(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
for (const item of value) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const detail = normalizeDetail((item as { question?: unknown }).question)
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's `liveToolDetail`: parse the raw arguments and take the first
|
||||
* present key of {@link LIVE_TOOL_DETAIL_KEYS}, falling back to the tool name.
|
||||
* @param name - tool-call name.
|
||||
* @param argsRaw - the call's raw `arguments` JSON string.
|
||||
*/
|
||||
export function liveToolDetail(name: string, argsRaw: string): string {
|
||||
let args: unknown
|
||||
try {
|
||||
args = JSON.parse(argsRaw)
|
||||
} catch {
|
||||
return normalizeDetail(name)
|
||||
}
|
||||
if (args === null || typeof args !== 'object') return normalizeDetail(name)
|
||||
const record = args as Record<string, unknown>
|
||||
for (const key of LIVE_TOOL_DETAIL_KEYS) {
|
||||
if (!(key in record)) continue
|
||||
const value = record[key]
|
||||
const detail = key === 'questions' ? questionDetail(value) : normalizeDetail(value)
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
return normalizeDetail(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — a tool call, an image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** One announced or dispatched call inside the current range. */
|
||||
interface CallRecord {
|
||||
callId: string
|
||||
name: string
|
||||
args: string
|
||||
seq: number
|
||||
/** A paired `tool/result` was observed in the scanned window. */
|
||||
settled: boolean
|
||||
/** Announced by the model but never dispatched (the host's `PreparingToolCall`). */
|
||||
preparing: boolean
|
||||
}
|
||||
|
||||
/** A tool-call content block under BOTH durable vocabularies (0.1.7 `tool-call`; older logs `tool_use`). */
|
||||
function announcedCalls(content: unknown): { callId: string; name: string; args: string }[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
const found: { callId: string; name: string; args: string }[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; id?: unknown; callId?: unknown; name?: unknown; arguments?: unknown }
|
||||
if (candidate.type !== 'tool-call' && candidate.type !== 'tool_use') continue
|
||||
const rawId = candidate.id ?? candidate.callId
|
||||
if (typeof rawId !== 'string' || typeof candidate.name !== 'string') continue
|
||||
found.push({
|
||||
callId: rawId,
|
||||
name: candidate.name,
|
||||
args: typeof candidate.arguments === 'string' ? candidate.arguments : '',
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The callId of one `tool/result` event under both logged shapes. */
|
||||
function resultCallId(data: Record<string, unknown>): string | undefined {
|
||||
const message = data.message as { source?: { callId?: unknown }; toolCallId?: unknown } | undefined
|
||||
for (const candidate of [data.callId, message?.source?.callId, message?.toolCallId, data.toolCallId]) {
|
||||
if (typeof candidate === 'string' && candidate !== '') return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Rank one range's calls into the host's summary shape. */
|
||||
function summarize(calls: readonly CallRecord[], live: boolean): ProcessActivitySummary {
|
||||
const counts = new Map<ProcessActivity, number>()
|
||||
let running: ProcessActivity | undefined
|
||||
let preparing: true | undefined
|
||||
let runningDetail = ''
|
||||
let runningSeq = -Infinity
|
||||
// The host counts in FORWARD member order, so a count tie ranks by first
|
||||
// appearance; this fold collects backwards and must restore that order.
|
||||
const ordered = calls.slice().sort((left, right) => left.seq - right.seq)
|
||||
for (const call of ordered) {
|
||||
const kind = activityOf(call.name)
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
||||
if (call.settled || !live) continue
|
||||
// The host selects on `>=` in event order: the newest unsettled call wins.
|
||||
if (call.seq < runningSeq) continue
|
||||
runningSeq = call.seq
|
||||
running = kind
|
||||
preparing = call.preparing ? true : undefined
|
||||
runningDetail = call.preparing ? (kind === 'tools' ? call.name : '') : liveToolDetail(call.name, call.args)
|
||||
}
|
||||
const ranked = [...counts].map(([kind, count]) => ({ kind, count }))
|
||||
ranked.sort((left, right) => right.count - left.count)
|
||||
return {
|
||||
counts: ranked,
|
||||
...(running === undefined ? {} : { running }),
|
||||
runningDetail,
|
||||
...(preparing === true ? { preparing: true as const } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Cap one detail line without cutting a surrogate pair in half. */
|
||||
function truncateText(text: string): string {
|
||||
const chars = graphemes(text)
|
||||
if (chars.length <= PROCESS_TEXT_MAX_CHARS) return text
|
||||
return `${chars.slice(0, PROCESS_TEXT_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
}
|
||||
|
||||
/** The calls of the range currently being collected. */
|
||||
interface Range {
|
||||
calls: CallRecord[]
|
||||
ids: Set<string>
|
||||
}
|
||||
|
||||
/** Start an empty range. */
|
||||
function openRange(): Range {
|
||||
return { calls: [], ids: new Set() }
|
||||
}
|
||||
|
||||
/** Append one call to a range (deduped by callId). */
|
||||
function addCall(range: Range, call: CallRecord): void {
|
||||
if (range.ids.has(call.callId)) return
|
||||
range.calls.push(call)
|
||||
range.ids.add(call.callId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a session event log into the host's merged activity view: the newest
|
||||
* range's summary and the newest text line. Scans BACKWARD from the end and
|
||||
* stops at the range it reports, so a long history costs only its recent tail.
|
||||
*
|
||||
* @param events - the session's append-only event log (oldest → newest).
|
||||
* @param options - `live` (whether the session is executing; a settled one
|
||||
* reports no running tool, like the host's closed groups) and `maxEvents`.
|
||||
* @returns the current summary plus the optional detail fields.
|
||||
*/
|
||||
export function foldProcess(
|
||||
events: readonly SidebarSessionEvent[],
|
||||
options: { live: boolean; maxEvents?: number },
|
||||
): ProcessFold {
|
||||
const live = options.live
|
||||
const maxEvents = Math.max(1, options.maxEvents ?? PROCESS_WINDOW_EVENTS)
|
||||
|
||||
let range = openRange()
|
||||
/** The newest NON-EMPTY range (the fallback when the newest one is empty). */
|
||||
let answer: CallRecord[] | undefined
|
||||
/** Whether {@link answer} is the newest range (only then can it be running). */
|
||||
let answerLive = false
|
||||
const settled = new Set<string>()
|
||||
let latestText: string | undefined
|
||||
let lastEventTime: number | undefined
|
||||
let crossed = 0
|
||||
|
||||
for (let index = events.length - 1, scanned = 0; index >= 0; index -= 1) {
|
||||
if (scanned >= maxEvents) break
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
scanned += 1
|
||||
lastEventTime ??= event.time
|
||||
const { type, data } = event
|
||||
|
||||
if (type === 'tool/result') {
|
||||
const callId = resultCallId(data)
|
||||
if (callId !== undefined) settled.add(callId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'tool/call') {
|
||||
const callId = typeof data.callId === 'string' && data.callId !== '' ? data.callId : `anon-${event.seq}`
|
||||
addCall(range, {
|
||||
callId,
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
seq: event.seq,
|
||||
settled: settled.has(callId),
|
||||
preparing: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (type !== 'assistant/message' && type !== 'user/message') continue
|
||||
const message = type === 'assistant/message'
|
||||
? (data.message as { content?: unknown } | undefined)
|
||||
: undefined
|
||||
if (message !== undefined) {
|
||||
// Announced calls belong to the range that FOLLOWS their assistant
|
||||
// message — the range being collected right now.
|
||||
for (const announced of announcedCalls(message.content)) {
|
||||
const isSettled = settled.has(announced.callId)
|
||||
addCall(range, {
|
||||
callId: announced.callId,
|
||||
name: announced.name,
|
||||
args: announced.args,
|
||||
seq: event.seq,
|
||||
settled: isSettled,
|
||||
preparing: !isSettled,
|
||||
})
|
||||
}
|
||||
}
|
||||
const text = message === undefined ? undefined : contentText(message.content)
|
||||
if (text !== undefined) latestText ??= text
|
||||
// A text-bearing assistant message and a user message both END the range
|
||||
// to their left (the host's flush points).
|
||||
if (type !== 'user/message' && text === undefined) continue
|
||||
if (range.calls.length > 0) {
|
||||
// The range just closed is the newest one that did anything.
|
||||
answer = range.calls
|
||||
answerLive = crossed === 0
|
||||
break
|
||||
}
|
||||
// Nothing happened in this range: step past it into the previous one, once.
|
||||
crossed += 1
|
||||
if (crossed > 1) break
|
||||
range = openRange()
|
||||
}
|
||||
|
||||
// `answer` is set when a boundary closed a range that did something; the
|
||||
// range still in hand covers two ends of the log: the whole log (no boundary
|
||||
// at all — that range IS the newest) and the one the scan ran out of events
|
||||
// in (a fallback range, so it is never live).
|
||||
const calls = answer ?? range.calls
|
||||
const newest = answer === undefined ? crossed === 0 : answerLive
|
||||
return {
|
||||
current: summarize(calls, live && newest),
|
||||
...(latestText === undefined ? {} : { text: truncateText(latestText) }),
|
||||
...(lastEventTime === undefined ? {} : { lastEventTime }),
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's session events.
|
||||
* The host batch route feeds raw session events into this parser; the client
|
||||
* only receives the already-folded `LastActivity` map. Renders nothing
|
||||
* itself — the SubagentView component turns this into the card's status
|
||||
* lines. Kept framework-free so the parser is unit-testable in the node
|
||||
* environment.
|
||||
*/
|
||||
import type { SidebarSessionEvent } from './context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a session event log into the last text output + last tool call (each
|
||||
* is the LAST occurrence in event order). Lifecycle events and raw stream
|
||||
* rows are ignored — the card shows what the subagent is doing right now,
|
||||
* not its plumbing. (DSH 0.1.5 publishes the raw deltas as process-local
|
||||
* frames instead of log events, so only the assembled `assistant/message`
|
||||
* reaches this scan.) The scan runs BACKWARD from the newest
|
||||
* event and stops once both fields are found, so a long history costs only
|
||||
* the recent tail in the common case.
|
||||
* @param events - the session's append-only event log (oldest → newest).
|
||||
* @param maxMessages - optional message-boundary window: only the tail's
|
||||
* last `maxMessages` surface messages (`user/message`, `assistant/message`)
|
||||
* and the events between them are considered, mirroring the old
|
||||
* `subagents.history({ maxMessages })` window. Stale activity older than
|
||||
* the window is never surfaced, and a long log is never scanned in full.
|
||||
* @returns the last text and/or tool call; an empty object when the log has neither.
|
||||
*/
|
||||
export function lastActivity(
|
||||
events: readonly SidebarSessionEvent[],
|
||||
maxMessages = Infinity,
|
||||
): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
let messagesSeen = 0
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
if (text !== undefined && tool !== undefined) break
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const { type, data } = event
|
||||
if (type === 'user/message' || type === 'assistant/message') {
|
||||
messagesSeen += 1
|
||||
if (messagesSeen > maxMessages) break
|
||||
} else if (messagesSeen >= maxMessages) {
|
||||
// The window already holds its `maxMessages` messages: anything older
|
||||
// than the oldest in-window message sits outside the recent window.
|
||||
continue
|
||||
}
|
||||
if (text === undefined && type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (tool === undefined && type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
+51
-36
@@ -4,42 +4,44 @@
|
||||
*
|
||||
* The route takes the already-resolved topology root (`rootSessionId`),
|
||||
* enumerates the whole descendant tree ONCE through the host subagent
|
||||
* runtime (`ctx.get('subagents')` / `listDescendants`), keeps only rows the
|
||||
* catalog reports running (`activity: 'running'` — the same gate the client
|
||||
* renders cards on), and folds the newest text/tool activity from each
|
||||
* child's attached session event log. It never touches DSH source and never
|
||||
* reads the model's `job_output` cursor.
|
||||
* runtime (`ctx.get('subagents')` / `listDescendants`), and folds EVERY child
|
||||
* session's newest process range with {@link foldProcess} — the plugin's port
|
||||
* of the main agent's merged process summary (category counts + the one
|
||||
* running call + its detail). It never touches DSH source and never reads the
|
||||
* model's `job_output` cursor.
|
||||
*
|
||||
* Folding a settled child is cheap by construction: the default fold stops at
|
||||
* the first range boundary it can report, so a child that is idle between
|
||||
* turns costs a handful of event reads (`snapshotEvents()` itself returns the
|
||||
* session's cached frozen snapshot, not a per-call copy).
|
||||
*
|
||||
* Degradation contract:
|
||||
* - `ctx.get('subagents')` missing or `listDescendants` failure → 503 (the
|
||||
* Subagent page has no topology to show in such deployments anyway).
|
||||
* - One child's events missing/corrupt → that child is skipped, the rest of
|
||||
* the batch still returns.
|
||||
* - One child's events missing/corrupt → that child is still REPORTED (its
|
||||
* `running` flag is the catalog's), just without a summary; the rest of the
|
||||
* batch is unaffected.
|
||||
*/
|
||||
import type { Context, SidebarSubagentsService } from './context-types.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarChildLiveView,
|
||||
SidebarSubagentsService,
|
||||
} from './context-types.ts'
|
||||
import { SIDE_LABEL_PREFIX } from './sidechat-core.ts'
|
||||
import { lastActivity, type LastActivity } from './subagent-activity.ts'
|
||||
import { foldProcess } from './process-activity.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The live-preview routes of the /sidebar JSON API. */
|
||||
export interface SidebarSubagentLiveRoutes {
|
||||
/**
|
||||
* Fold one tree's running subagent histories into a compact live map.
|
||||
* Fold one tree's subagent activity into a compact live map.
|
||||
* @param payload - `{ rootSessionId }`.
|
||||
* @returns `{ live: Record<childSessionId, LastActivity> }`; children with
|
||||
* no text/tool yet are omitted.
|
||||
* @returns `{ live: Record<sessionId, SidebarChildLiveView> }` over the
|
||||
* whole descendant catalog plus the topology root.
|
||||
*/
|
||||
live(payload: unknown): Promise<{ live: Record<string, LastActivity> }>
|
||||
live(payload: unknown): Promise<{ live: Record<string, SidebarChildLiveView> }>
|
||||
}
|
||||
|
||||
/**
|
||||
* The recent-message window of the live preview: only the last 12 surface
|
||||
* messages of a child's log are folded, matching the old per-card
|
||||
* `subagents.history({ maxMessages: 12 })` window. Keeps stale tool calls
|
||||
* out of the preview and bounds the backward scan per child.
|
||||
*/
|
||||
export const LIVE_WINDOW_MESSAGES = 12
|
||||
|
||||
/**
|
||||
* Build the live-preview routes bound to the plugin context.
|
||||
* @param ctx - host plugin context.
|
||||
@@ -67,25 +69,38 @@ export function buildSubagentLiveApi(ctx: Context): SidebarSubagentLiveRoutes {
|
||||
)
|
||||
}
|
||||
|
||||
const live: Record<string, LastActivity> = {}
|
||||
const live: Record<string, SidebarChildLiveView> = {}
|
||||
/**
|
||||
* Fold one session into the map. `running` is the caller-visible activity
|
||||
* flag: the catalog's for a child, undefined for the topology ROOT (the
|
||||
* host cannot classify it, and the page already knows the answer from the
|
||||
* session list). The fold's own `live` gate is that flag when it exists,
|
||||
* and the LOG's evidence for the root.
|
||||
*/
|
||||
const foldInto = (sessionId: string, running: boolean | undefined): void => {
|
||||
const view: SidebarChildLiveView = running === undefined ? {} : { running }
|
||||
try {
|
||||
const events = ctx.sessions.get(sessionId)?.snapshotEvents() ?? []
|
||||
const fold = foldProcess(events, { live: running !== false })
|
||||
if (fold.current.counts.length > 0 || fold.current.running !== undefined) {
|
||||
view.summary = fold.current
|
||||
}
|
||||
if (fold.text !== undefined) view.text = fold.text
|
||||
if (fold.lastEventTime !== undefined) view.lastEventTime = fold.lastEventTime
|
||||
} catch {
|
||||
// One session's event log is not readable: keep the row (a child's
|
||||
// running flag is the catalog's) and skip only its details.
|
||||
}
|
||||
live[sessionId] = view
|
||||
}
|
||||
|
||||
foldInto(rootSessionId, undefined)
|
||||
for (const entry of descendants) {
|
||||
// Same gate the client renders cards on: only catalog-running
|
||||
// children get live lines (spec: "仅对 running 且非 Side Chat").
|
||||
if (entry.kind !== 'child' || entry.activity !== 'running') continue
|
||||
if (entry.kind !== 'child') continue
|
||||
// Side Chat threads ride the subagent origin but are sidebar tabs,
|
||||
// never topology — keep them out of the live map too.
|
||||
if (entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) continue
|
||||
try {
|
||||
const activity = lastActivity(
|
||||
ctx.sessions.get(entry.id)?.snapshotEvents() ?? [],
|
||||
LIVE_WINDOW_MESSAGES,
|
||||
)
|
||||
if (activity.text !== undefined || activity.tool !== undefined) {
|
||||
live[entry.id] = activity
|
||||
}
|
||||
} catch {
|
||||
// One child's event log is not readable: skip only that child.
|
||||
}
|
||||
foldInto(entry.id, entry.activity === 'running')
|
||||
}
|
||||
return { live }
|
||||
},
|
||||
|
||||
+51
-1
@@ -6,7 +6,7 @@
|
||||
* interpolation.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
||||
import { LOCALE_NS, attachBetterLocale, attachLocale, chatT, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
||||
import { localeDicts } from '../src/client/chunks/locale.tsx'
|
||||
|
||||
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
||||
@@ -121,6 +121,56 @@ describe('locales (DSH i18n following)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A locale service whose `bind` reads instance state, exactly like DSH's own
|
||||
* (`LocaleRuntime.bind` touches `this.bound`). A detached call — the natural
|
||||
* refactor `const bind = service.bind; bind('chat')` — throws here, which is
|
||||
* what a real host did before the mount lane caught it.
|
||||
*/
|
||||
class FakeLocaleWithBind {
|
||||
active = 'zh'
|
||||
private readonly dicts: Record<string, Record<string, string>> = {
|
||||
chat: { 'message.stepProcess.done.read': '已读取文件' },
|
||||
}
|
||||
getSnapshot(): { active: string } {
|
||||
return { active: this.active }
|
||||
}
|
||||
bind(ns: string): (key: string, params?: Record<string, string | number>) => string {
|
||||
// The `this` read that makes a detached call blow up.
|
||||
const dict: Record<string, string> = this.dicts[ns] ?? {}
|
||||
return (key) => dict[key] ?? key
|
||||
}
|
||||
}
|
||||
|
||||
describe('locales (host chat namespace bridge)', () => {
|
||||
it('reads the host namespace through a METHOD call on the attached service', () => {
|
||||
attachLocale(new FakeLocaleWithBind())
|
||||
expect(chatT('message.stepProcess.done.read')).toBe('已读取文件')
|
||||
})
|
||||
|
||||
it('answers undefined for a namespace or key the host does not serve', () => {
|
||||
attachLocale(new FakeLocaleWithBind())
|
||||
// An unregistered host namespace resolves to the key itself: the caller
|
||||
// must see "unavailable", never the raw `message.…` key.
|
||||
expect(chatT('message.stepProcess.read')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers undefined when the attached face has no bind at all', () => {
|
||||
attachLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
attachLocale(undefined)
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never lets a throwing host seam break a render', () => {
|
||||
attachLocale({
|
||||
getSnapshot: () => ({ active: 'en' }),
|
||||
bind: () => { throw new TypeError("Cannot read properties of undefined (reading 'bound')") },
|
||||
})
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** Minimal fake of the better-locale override store (the subset t()/isZh() read). */
|
||||
class FakeBetterLocale {
|
||||
active: string | undefined = undefined
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* The ported main-agent process fold: category mapping, live-detail
|
||||
* extraction, and the range cut (`foldProcess`). These pin the parity claims
|
||||
* the live route and the Tasks page cards rest on, so a later host change
|
||||
* shows up as a red test rather than a silently different label.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PROCESS_DETAIL_MAX_CHARS,
|
||||
PROCESS_TEXT_MAX_CHARS,
|
||||
activityOf,
|
||||
contentText,
|
||||
foldProcess,
|
||||
liveToolDetail,
|
||||
type ProcessActivity,
|
||||
} from '../src/process-activity.ts'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
/** One raw session event. */
|
||||
const entry = (type: string, data: Record<string, unknown>, seq = 0, time = 0): SidebarSessionEvent => ({
|
||||
type, seq, time, data,
|
||||
})
|
||||
|
||||
/** A `tool/call` event. */
|
||||
const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent =>
|
||||
entry('tool/call', { callId, name, arguments: args }, seq, time)
|
||||
|
||||
/** A `tool/result` event paired by callId (0.1.7's tool-role message shape). */
|
||||
const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent =>
|
||||
entry('tool/result', { message: { source: { kind: 'tool', callId }, content: [{ type: 'text', text: 'ok' }] } }, seq, time)
|
||||
|
||||
/** An assistant message carrying text and/or announced tool-call blocks. */
|
||||
const assistant = (content: unknown[], seq = 0, time = 0): SidebarSessionEvent =>
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content } }, seq, time)
|
||||
|
||||
/** A user message (a range boundary). */
|
||||
const user = (text = 'continue', seq = 0, time = 0): SidebarSessionEvent =>
|
||||
entry('user/message', { content: [{ type: 'text', text }] }, seq, time)
|
||||
|
||||
describe('process activity: the host category map, verbatim', () => {
|
||||
const cases: [string, ProcessActivity][] = [
|
||||
['read', 'read'],
|
||||
['read_image', 'readImage'],
|
||||
['grep', 'search'],
|
||||
['glob', 'search'],
|
||||
['bash_inspect', 'search'],
|
||||
['write', 'write'],
|
||||
['edit', 'edit'],
|
||||
['apply_patch', 'edit'],
|
||||
['bash', 'commands'],
|
||||
['pwsh', 'commands'],
|
||||
['exec_command', 'commands'],
|
||||
['write_stdin', 'commands'],
|
||||
['terminal_send', 'commands'],
|
||||
['run_code', 'code'],
|
||||
['web_search', 'webSearch'],
|
||||
['web_fetch', 'webFetch'],
|
||||
['subagent', 'subagents'],
|
||||
['subagent_control', 'subagents'],
|
||||
['todo_write', 'plan'],
|
||||
['create_goal', 'plan'],
|
||||
['update_goal', 'plan'],
|
||||
['get_goal', 'plan'],
|
||||
['ask_user_question', 'questions'],
|
||||
['request_user_input', 'questions'],
|
||||
['something_else', 'tools'],
|
||||
['', 'tools'],
|
||||
]
|
||||
for (const [name, kind] of cases) {
|
||||
it(`${name || '(empty)'} → ${kind}`, () => {
|
||||
expect(activityOf(name)).toBe(kind)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('process activity: live tool detail', () => {
|
||||
it('takes the first present key of the host priority list', () => {
|
||||
expect(liveToolDetail('bash', JSON.stringify({ command: 'npm test', path: 'x' }))).toBe('npm test')
|
||||
// `title` beats `command` because it is earlier in the host's list.
|
||||
expect(liveToolDetail('bash', JSON.stringify({ command: 'a', title: 'Build' }))).toBe('Build')
|
||||
})
|
||||
|
||||
it('joins string arrays and reads the first question', () => {
|
||||
expect(liveToolDetail('grep', JSON.stringify({ queries: ['one', 'two'] }))).toBe('one, two')
|
||||
expect(liveToolDetail('ask_user_question', JSON.stringify({
|
||||
questions: [{ question: 'first?' }, { question: 'second?' }],
|
||||
}))).toBe('first?')
|
||||
})
|
||||
|
||||
it('collapses whitespace and truncates on a grapheme boundary', () => {
|
||||
const long = 'a'.repeat(PROCESS_DETAIL_MAX_CHARS + 40)
|
||||
const detail = liveToolDetail('bash', JSON.stringify({ command: long }))
|
||||
expect(detail.endsWith('…')).toBe(true)
|
||||
expect(Array.from(detail).length).toBe(PROCESS_DETAIL_MAX_CHARS)
|
||||
expect(liveToolDetail('bash', JSON.stringify({ command: 'a\n\n b\tc' }))).toBe('a b c')
|
||||
})
|
||||
|
||||
it('falls back to the tool name for unusable arguments', () => {
|
||||
expect(liveToolDetail('bash', 'not json')).toBe('bash')
|
||||
expect(liveToolDetail('bash', '"scalar"')).toBe('bash')
|
||||
expect(liveToolDetail('bash', JSON.stringify({ unknownKey: 'x' }))).toBe('bash')
|
||||
expect(liveToolDetail('bash', '')).toBe('bash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process activity: content text', () => {
|
||||
it('joins text blocks and ignores everything else', () => {
|
||||
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'tool-call', id: 'c' }])).toBe('hello')
|
||||
expect(contentText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb')
|
||||
expect(contentText(undefined)).toBeUndefined()
|
||||
expect(contentText('nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('process activity: the range fold', () => {
|
||||
it('returns an empty summary for an empty log', () => {
|
||||
expect(foldProcess([], { live: true })).toEqual({ current: { counts: [], runningDetail: '' } })
|
||||
})
|
||||
|
||||
it('counts distinct calls per category and ranks by count', () => {
|
||||
const fold = foldProcess([
|
||||
user('go', 1, 10),
|
||||
call('c1', 'read', '{"path":"a"}', 2, 11),
|
||||
call('c2', 'read', '{"path":"b"}', 3, 12),
|
||||
call('c3', 'bash', '{"command":"ls"}', 4, 13),
|
||||
], { live: true })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'read', count: 2 }, { kind: 'commands', count: 1 }])
|
||||
})
|
||||
|
||||
it('breaks count ties by first appearance', () => {
|
||||
const fold = foldProcess([
|
||||
call('c1', 'bash', '{}', 1, 1),
|
||||
call('c2', 'read', '{}', 2, 2),
|
||||
], { live: true })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'read', count: 1 }])
|
||||
})
|
||||
|
||||
it('reports the newest unsettled call as running, with its detail', () => {
|
||||
const fold = foldProcess([
|
||||
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
|
||||
result('c1', 2, 2),
|
||||
call('c2', 'grep', '{"pattern":"host"}', 3, 3),
|
||||
], { live: true })
|
||||
expect(fold.current.running).toBe('search')
|
||||
expect(fold.current.runningDetail).toBe('host')
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'search', count: 1 }])
|
||||
})
|
||||
|
||||
it('reports no running tool once the session settled (the host closed group)', () => {
|
||||
const fold = foldProcess([call('c1', 'bash', '{"command":"ls"}', 1, 1)], { live: false })
|
||||
expect(fold.current.running).toBeUndefined()
|
||||
expect(fold.current.runningDetail).toBe('')
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
|
||||
})
|
||||
|
||||
it('counts and flags a call the model announced but never dispatched', () => {
|
||||
const fold = foldProcess([
|
||||
user('go', 1, 5),
|
||||
assistant([{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{"command":"ls"}' }], 2, 6),
|
||||
], { live: true })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
|
||||
expect(fold.current.running).toBe('commands')
|
||||
expect(fold.current.preparing).toBe(true)
|
||||
// The host shows no detail while preparing (the name only for `tools`).
|
||||
expect(fold.current.runningDetail).toBe('')
|
||||
expect(foldProcess([
|
||||
assistant([{ type: 'tool-call', id: 'c1', name: 'mystery_tool' }], 2, 6),
|
||||
], { live: true }).current.runningDetail).toBe('mystery_tool')
|
||||
})
|
||||
|
||||
it('does not double count an announced call that was dispatched and settled', () => {
|
||||
const fold = foldProcess([
|
||||
user('go', 1, 5),
|
||||
assistant([{ type: 'tool-call', id: 'c1', name: 'read', arguments: '{"path":"a"}' }], 2, 6),
|
||||
call('c1', 'read', '{"path":"a"}', 3, 7),
|
||||
result('c1', 4, 8),
|
||||
], { live: true })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }])
|
||||
expect(fold.current.running).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops at the newest range boundary and keeps the newest text', () => {
|
||||
// The stale read lives in the PREVIOUS range: a reply ended it.
|
||||
const fold = foldProcess([
|
||||
user('go', 1, 10),
|
||||
call('c1', 'read', '{"path":"old"}', 2, 11),
|
||||
assistant([{ type: 'text', text: 'first answer' }], 3, 12),
|
||||
call('c2', 'bash', '{"command":"npm test"}', 4, 13),
|
||||
assistant([{ type: 'text', text: 'second answer' }], 5, 14),
|
||||
], { live: false })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
|
||||
expect(fold.text).toBe('second answer')
|
||||
expect(fold.lastEventTime).toBe(14)
|
||||
})
|
||||
|
||||
it('falls back to the previous range when the newest one is empty', () => {
|
||||
// The child replied and then did nothing: reporting the empty newest range
|
||||
// would show "nothing happened" for a node that just did real work.
|
||||
const fold = foldProcess([
|
||||
user('go', 1, 10),
|
||||
call('c1', 'read', '{"path":"a"}', 2, 11),
|
||||
call('c2', 'bash', '{"command":"npm test"}', 3, 12),
|
||||
assistant([{ type: 'text', text: 'all green' }], 4, 13),
|
||||
], { live: false })
|
||||
// Forwards order is read → bash, so the tie ranks read first.
|
||||
expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }])
|
||||
expect(fold.text).toBe('all green')
|
||||
// The fallback range is CLOSED: it can never report a running tool.
|
||||
expect(fold.current.running).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never reports the fallback range as running even while the session lives', () => {
|
||||
const fold = foldProcess([
|
||||
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
|
||||
assistant([{ type: 'text', text: 'done with that' }], 2, 2),
|
||||
], { live: true })
|
||||
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
|
||||
expect(fold.current.running).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives up after one empty range instead of scanning the whole log', () => {
|
||||
const events = [
|
||||
call('ancient', 'read', '{"path":"old"}', 1, 1),
|
||||
assistant([{ type: 'text', text: 'first' }], 2, 2),
|
||||
assistant([{ type: 'text', text: 'second' }], 3, 3),
|
||||
assistant([{ type: 'text', text: 'third' }], 4, 4),
|
||||
]
|
||||
// Two replies in a row leave TWO empty ranges before the ancient call; the
|
||||
// fold stops after the first empty one and reports an empty summary.
|
||||
const fold = foldProcess(events, { live: false })
|
||||
expect(fold.current.counts).toEqual([])
|
||||
expect(fold.text).toBe('third')
|
||||
})
|
||||
|
||||
it('stops the backward scan at the event cap', () => {
|
||||
const events: SidebarSessionEvent[] = []
|
||||
for (let index = 0; index < 900; index += 1) {
|
||||
events.push(call(`c${index}`, 'read', `{"path":"${index}"}`, index, index))
|
||||
}
|
||||
const fold = foldProcess(events, { live: true })
|
||||
expect(fold.current.counts[0]?.count).toBeLessThan(900)
|
||||
expect(fold.current.counts[0]?.kind).toBe('read')
|
||||
})
|
||||
|
||||
it('caps the text line without splitting the ellipsis', () => {
|
||||
const long = 'x'.repeat(PROCESS_TEXT_MAX_CHARS + 30)
|
||||
const fold = foldProcess([assistant([{ type: 'text', text: long }], 1, 1)], { live: false })
|
||||
expect(Array.from(fold.text ?? '').length).toBe(PROCESS_TEXT_MAX_CHARS)
|
||||
expect(fold.text?.endsWith('…')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The Tasks page's borrowed main-conversation wording: the live activity line
|
||||
* and the settled range title, composed exactly the way the host composes
|
||||
* them, plus the fallback that keeps a host rename from printing raw keys.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { doneActivityTitle, liveActivityLabel, type ChatTranslate } from '../src/client/process-labels.ts'
|
||||
import type { ProcessActivitySummary } from '../src/process-activity.ts'
|
||||
|
||||
/** The host's `chat` dictionary for the keys this module reads (zh wording). */
|
||||
const HOST: Record<string, string> = {
|
||||
'message.stepProcess.thinking': '正在分析请求',
|
||||
'message.stepProcess.commands': '正在运行命令',
|
||||
'message.stepProcess.read': '正在读取文件',
|
||||
'message.stepProcess.tools': '正在调用工具',
|
||||
'message.stepProcess.prepare.commands': '准备运行命令',
|
||||
'message.stepProcess.prepare.tools': '准备调用工具',
|
||||
'message.stepProcess.done.thinking': '已完成分析',
|
||||
'message.stepProcess.done.read': '已读取文件',
|
||||
'message.stepProcess.done.commands': '执行了命令',
|
||||
'message.stepProcess.done.search': '已搜索代码',
|
||||
'message.stepProcess.done.tools': '已调用工具',
|
||||
'message.stepProcess.joinTwo': '{first}并{second}',
|
||||
'message.stepProcess.comma': ',',
|
||||
'message.stepProcess.sharedPrefix': '已',
|
||||
'message.stepProcess.more': '{title}等',
|
||||
'message.turnProcess.separator': ' · ',
|
||||
}
|
||||
|
||||
/** The host translator, with `{name}` interpolation like DSH's own. */
|
||||
const t: ChatTranslate = (key, params) => {
|
||||
const template = HOST[key]
|
||||
if (template === undefined) return undefined
|
||||
if (params === undefined) return template
|
||||
let text = template
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
return text
|
||||
}
|
||||
|
||||
/** A summary built from counts. */
|
||||
function summary(over: Partial<ProcessActivitySummary> = {}): ProcessActivitySummary {
|
||||
return { counts: [], runningDetail: '', ...over }
|
||||
}
|
||||
|
||||
describe('live activity label', () => {
|
||||
it('renders the category wording plus the running detail', () => {
|
||||
expect(liveActivityLabel(summary({
|
||||
counts: [{ kind: 'commands', count: 2 }],
|
||||
running: 'commands',
|
||||
runningDetail: 'npm run build',
|
||||
}), t)).toBe('正在运行命令 · npm run build')
|
||||
})
|
||||
|
||||
it('falls back to the host thinking wording while nothing is dispatched', () => {
|
||||
expect(liveActivityLabel(summary({ counts: [{ kind: 'read', count: 1 }] }), t)).toBe('正在分析请求')
|
||||
expect(liveActivityLabel(undefined, t)).toBe('正在分析请求')
|
||||
})
|
||||
|
||||
it('uses the prepare wording without a detail', () => {
|
||||
expect(liveActivityLabel(summary({
|
||||
running: 'commands',
|
||||
runningDetail: 'ignored while preparing',
|
||||
preparing: true,
|
||||
}), t)).toBe('准备运行命令')
|
||||
})
|
||||
|
||||
it('answers undefined when the host namespace is unavailable', () => {
|
||||
const missing: ChatTranslate = () => undefined
|
||||
expect(liveActivityLabel(summary({ running: 'commands' }), missing)).toBeUndefined()
|
||||
// …and falls back to the detail it does have when the host has one.
|
||||
expect(liveActivityLabel(summary({ running: 'commands', runningDetail: 'npm test' }), missing))
|
||||
.toBe('npm test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settled range title', () => {
|
||||
it('prints one category without counts', () => {
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 3 }] }), t)).toBe('已读取文件')
|
||||
})
|
||||
|
||||
it('joins two categories and drops the shared prefix from the second', () => {
|
||||
expect(doneActivityTitle(summary({
|
||||
counts: [{ kind: 'read', count: 2 }, { kind: 'search', count: 1 }],
|
||||
}), t)).toBe('已读取文件并搜索代码')
|
||||
})
|
||||
|
||||
it('joins three categories with the host comma', () => {
|
||||
expect(doneActivityTitle(summary({
|
||||
counts: [
|
||||
{ kind: 'read', count: 3 },
|
||||
{ kind: 'search', count: 2 },
|
||||
{ kind: 'commands', count: 1 },
|
||||
],
|
||||
}), t)).toBe('已读取文件,已搜索代码,执行了命令')
|
||||
})
|
||||
|
||||
it('appends the host suffix beyond the top three', () => {
|
||||
const title = doneActivityTitle(summary({
|
||||
counts: [
|
||||
{ kind: 'read', count: 4 },
|
||||
{ kind: 'search', count: 3 },
|
||||
{ kind: 'commands', count: 2 },
|
||||
{ kind: 'tools', count: 1 },
|
||||
],
|
||||
}), t)
|
||||
// The host lowercases the continuation labels but only the TWO-label
|
||||
// branch strips the shared "已" prefix, so these keep it.
|
||||
expect(title).toBe('已读取文件,已搜索代码,执行了命令等')
|
||||
})
|
||||
|
||||
it('answers the host thinking wording when nothing was called', () => {
|
||||
expect(doneActivityTitle(summary(), t)).toBe('已完成分析')
|
||||
expect(doneActivityTitle(undefined, t)).toBe('已完成分析')
|
||||
})
|
||||
|
||||
it('answers undefined when a category has no host wording', () => {
|
||||
const partial: ChatTranslate = key => (key === 'message.stepProcess.done.read' ? '已读取文件' : undefined)
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }] }), partial))
|
||||
.toBeUndefined()
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }] }), partial)).toBe('已读取文件')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentText, lastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent activity summary parser', () => {
|
||||
/** One raw session event. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarSessionEvent => ({
|
||||
type, seq: 0, time: 0, data,
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
// Text blocks join as paragraphs (newline-separated).
|
||||
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
|
||||
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
|
||||
expect(contentText(undefined)).toBeUndefined()
|
||||
expect(contentText('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lastActivity returns the LAST text output and the LAST tool call', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/start', { turn: 1 }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
message: { content: [{ type: 'text', text: '检查完毕' }] },
|
||||
}),
|
||||
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 2,
|
||||
message: { content: [{ type: 'text', text: '再看一眼' }] },
|
||||
}),
|
||||
])
|
||||
expect(live).toEqual({
|
||||
text: '再看一眼',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the log actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
expect(lastActivity([
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
])).toEqual({ text: 'ok' })
|
||||
})
|
||||
|
||||
it('lastActivity ignores lifecycle events, stream rows, and text-less messages', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/end', { turn: 1, reason: 'success' }),
|
||||
entry('step/start', { turn: 1, step: 1 }),
|
||||
entry('assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
|
||||
])
|
||||
expect(live).toEqual({})
|
||||
expect(lastActivity([])).toEqual({})
|
||||
})
|
||||
|
||||
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
|
||||
const live = lastActivity([
|
||||
entry('tool/call', { callId: 'c1' }),
|
||||
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
|
||||
it('lastActivity window excludes activity older than the last maxMessages messages', () => {
|
||||
// One stale tool call before the window: with maxMessages=1 only the
|
||||
// latest message (and the events around it) are considered.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 1)).toEqual({ text: 'new text' })
|
||||
|
||||
// The same log with a wider window surfaces the tool call again.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 2)).toEqual({
|
||||
text: 'new text',
|
||||
tool: { name: 'bash', args: '{"command":"old"}' },
|
||||
})
|
||||
|
||||
// Non-message events (tool calls) do not consume quota, but once the
|
||||
// window holds its messages, older events are dropped even without an
|
||||
// older message to stop at: the second message pushes the tool call out.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 1)).toEqual({})
|
||||
|
||||
// A tool call between the two in-window messages stays visible.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 2)).toEqual({
|
||||
text: 'first',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -125,7 +125,7 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } })
|
||||
return jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
@@ -177,7 +177,7 @@ describe('SubagentView live polling', () => {
|
||||
|
||||
// Settle the first request; only then does the next 3s tick fire.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } }))
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
@@ -217,7 +217,7 @@ describe('SubagentView live polling', () => {
|
||||
|
||||
// Settling the stale response must neither render nor schedule a poll.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } }))
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true, text: 'stale' } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
@@ -235,7 +235,7 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
|
||||
const live = body.rootSessionId === 'root' ? { a: { running: true, text: 'hello' } } : {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
|
||||
@@ -49,8 +49,20 @@ function ctxWith(subagents: unknown, sessions: unknown): Context {
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** One tool call event. */
|
||||
const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'tool/call', seq, time, data: { callId, name, arguments: args } })
|
||||
|
||||
/** A settled tool result for one call id. */
|
||||
const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'tool/result', seq, time, data: { message: { source: { kind: 'tool', callId } } } })
|
||||
|
||||
/** An assistant message carrying text. */
|
||||
const reply = (text: string, seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'assistant/message', seq, time, data: { message: { content: [{ type: 'text', text }] } } })
|
||||
|
||||
describe('subagents.live route', () => {
|
||||
it('returns non-empty activity for running children only', async () => {
|
||||
it('reports every child with its catalog flag and folded range', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('running-a', { label: 'A' }),
|
||||
@@ -62,64 +74,70 @@ describe('subagents.live route', () => {
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'running-a') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-b') {
|
||||
return session([
|
||||
{ type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-a') return session([reply('hello', 1, 1)])
|
||||
if (id === 'running-b') return session([call('c1', 'bash', '{"command":"ls"}', 1, 1)])
|
||||
return session([])
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: {
|
||||
'running-a': { text: 'hello' },
|
||||
'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } },
|
||||
},
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
// The topology root is reported too (its activity flag is the caller's).
|
||||
expect(live.root).toEqual({})
|
||||
expect(live['running-a']?.running).toBe(true)
|
||||
expect(live['running-a']?.text).toBe('hello')
|
||||
expect(live['running-b']?.summary).toEqual({
|
||||
counts: [{ kind: 'commands', count: 1 }],
|
||||
running: 'commands',
|
||||
runningDetail: 'ls',
|
||||
})
|
||||
// A settled child is still a row: its summary drives its card.
|
||||
expect(live.inactive?.running).toBe(false)
|
||||
// Side Chat threads and diagnostics are not topology.
|
||||
expect(live['side-chat']).toBeUndefined()
|
||||
expect(live['corrupt-row']).toBeUndefined()
|
||||
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
|
||||
})
|
||||
|
||||
it('omits children with no text/tool yet', async () => {
|
||||
it('folds a settled child from its last range that did something', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]),
|
||||
listDescendants: vi.fn(async () => [child('done', { activity: 'inactive', label: 'D' })]),
|
||||
}
|
||||
const sessions = { get: () => session([]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} })
|
||||
const events = [
|
||||
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
|
||||
result('c1', 2, 2),
|
||||
reply('all green', 3, 3),
|
||||
]
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) }))
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.done?.summary).toEqual({
|
||||
counts: [{ kind: 'commands', count: 1 }],
|
||||
runningDetail: '',
|
||||
})
|
||||
expect(live.done?.text).toBe('all green')
|
||||
expect(live.done?.lastEventTime).toBe(3)
|
||||
})
|
||||
|
||||
it('folds only activity inside the recent 12-message window', async () => {
|
||||
// One stale tool call sits before 13 user messages; the recent window
|
||||
// (the tail's last 12 messages) must not surface it.
|
||||
const staleTool: SidebarSessionEvent = {
|
||||
type: 'tool/call', seq: 0, time: 0,
|
||||
data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' },
|
||||
}
|
||||
const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({
|
||||
type: 'user/message', seq: i + 1, time: 0,
|
||||
data: { content: [{ type: 'text', text: `m${i}` }] },
|
||||
}))
|
||||
const recentText: SidebarSessionEvent = {
|
||||
type: 'assistant/message', seq: 99, time: 0,
|
||||
data: { message: { content: [{ type: 'text', text: 'recent' }] } },
|
||||
}
|
||||
it('stops the fold at the newest range boundary', async () => {
|
||||
// One stale tool call sits in an earlier range: the newest range is the
|
||||
// tail after the reply, so the stale call must not be counted.
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]),
|
||||
}
|
||||
const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { windowed: { text: 'recent' } },
|
||||
const events = [
|
||||
call('stale', 'bash', '{"command":"old"}', 1, 1),
|
||||
reply('done with the old work', 2, 2),
|
||||
call('fresh', 'read', '{"path":"a"}', 3, 3),
|
||||
]
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) }))
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.windowed?.summary).toEqual({
|
||||
counts: [{ kind: 'read', count: 1 }],
|
||||
running: 'read',
|
||||
runningDetail: 'a',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a child whose session log is unavailable without failing the batch', async () => {
|
||||
it('keeps a child whose session log is unreadable (its flag is the catalog\'s)', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('good', { label: 'Good' }),
|
||||
@@ -128,18 +146,14 @@ describe('subagents.live route', () => {
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'good') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'good') return session([reply('ok', 0, 0)])
|
||||
throw new Error('missing')
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { good: { text: 'ok' } },
|
||||
})
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.good?.text).toBe('ok')
|
||||
expect(live.bad).toEqual({ running: true })
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the subagent runtime is absent', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* the width-aware solver.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarChildLiveView } from '../src/context-types.ts'
|
||||
import {
|
||||
GRAPH_GAP_Y,
|
||||
GRAPH_LIVE_H,
|
||||
@@ -50,6 +51,12 @@ function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): num
|
||||
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
|
||||
}
|
||||
|
||||
/** One running node's live view: a merged activity row, no text tail. */
|
||||
const ACTIVITY: SidebarChildLiveView = {
|
||||
running: true,
|
||||
summary: { counts: [{ kind: 'read', count: 1 }], running: 'read', runningDetail: 'x' },
|
||||
}
|
||||
|
||||
describe('layoutTasksGraph', () => {
|
||||
it('places the root on row 0 and children one row below', () => {
|
||||
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
||||
@@ -98,11 +105,11 @@ describe('layoutTasksGraph', () => {
|
||||
expect(plain.boxes.get('mate')?.h).toBe(GRAPH_NODE_H)
|
||||
})
|
||||
|
||||
it('reserves the live tail row only when the tail carries a tool AND text', () => {
|
||||
it('reserves the live tail row only when the tail carries activity AND text', () => {
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('tool-only', 'root', false, { live: { tool: { name: 'read', args: 'x' } } }),
|
||||
agent('both', 'root', false, { live: { tool: { name: 'read', args: 'x' }, text: 'y' } }),
|
||||
agent('tool-only', 'root', false, { live: ACTIVITY }),
|
||||
agent('both', 'root', false, { live: { ...ACTIVITY, text: 'y' } }),
|
||||
]
|
||||
const layout = layoutTasksGraph(nodes)
|
||||
// One live row either way (the tool row, or the meta-styled thinking
|
||||
@@ -118,9 +125,7 @@ describe('layoutTasksGraph', () => {
|
||||
expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest)
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('busiest', 'root', false, {
|
||||
live: { tool: { name: 'read', args: 'x' }, text: 'y' }, tasks: [task('t1')],
|
||||
}),
|
||||
agent('busiest', 'root', false, { live: { ...ACTIVITY, text: 'y' }, tasks: [task('t1')] }),
|
||||
]
|
||||
const layout = layoutTasksGraph(nodes)
|
||||
expect(layout.boxes.get('busiest')?.h).toBe(tallest)
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
SidebarTeamMemberView,
|
||||
} from '../src/context-types.ts'
|
||||
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
|
||||
import type { LastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarChildLiveView } from '../src/context-types.ts'
|
||||
import type { WorkflowRunView } from '../src/workflow-runs.ts'
|
||||
|
||||
/** A summary row. */
|
||||
@@ -36,8 +36,9 @@ function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
|
||||
}
|
||||
|
||||
/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
|
||||
function running(...ids: string[]): Record<string, LastActivity> {
|
||||
return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }]))
|
||||
/** The live map of a set of RUNNING sessions (the new live-view shape). */
|
||||
function running(...ids: string[]): Record<string, SidebarChildLiveView> {
|
||||
return Object.fromEntries(ids.map(id => [id, { running: true, text: `${id} is working` }]))
|
||||
}
|
||||
|
||||
/** A workflow run view. */
|
||||
|
||||
Reference in New Issue
Block a user