From 69cc8014a7178e6a3cdc07ce221f0ac3ecfc5b70 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 12:05:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20subagent=20lineage=20?= =?UTF-8?q?=E9=81=8D=E5=8E=86=E6=94=B6=E6=95=9B=E4=B8=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20subagent-lineage=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rootAncestor / countSubagentDescendants / treeSessionIds 三份「沿 subagent-origin 链上行 + seen 集合防环 fail-soft」实现收敛到新模块 src/client/subagent-lineage.ts:核心走链是单个私有生成器 subagentOriginChain()(seen 防环、只在节点为 subagent 且有 parent 时 前行),三个公开函数迁入并基于它重写;isSideThreadSummary 一并迁入 (countSubagentDescendants 依赖它,迁入可避免 detect↔lineage 循环 依赖),subagent-detect.ts / subagent-jobs.ts re-export 全部迁出 符号,既有 import 调用点零变化。 语义逐字保留的等价性论证: - rootAncestor:链为空返回 start.id(原循环条件不满足即返回 current?.id);链非空返回 byId[末节点.parentId]?.id ?? sessionId (原循环退出时 current 恰为该行,断链/行缺失回退 sessionId 不变)。 - countSubagentDescendants:生成器在 seen.add 后 yield、消费方判 parentId === sessionId 即 break——与原循环体内判断顺序一致; descendant 的 running 判定、side-thread 过滤位置不变。 - treeSessionIds:根行缺失(byId[rootId] 无行)时任何上行都不可能 「访问到」根(原实现只匹配真实访问过的行),故提前返回空集; 根行自身(非 subagent)经 summary.id === rootId 特判入集;其余 行经链节点 parentId === rootId 判定,等价于原「上行走到 id === rootId 的行」——两种表述指向同一行(链上节点的 next 行)。 directSubagentCount 不迁移:它是对直接子行的单层过滤,没有上行 walk,无共享逻辑(背景侦查将其归入 walk 家族与实际代码不符)。 collectBranchIds 遍历的是 catalog 结构而非 session 链,保持不动。 --- src/client/subagent-detect.ts | 75 ++----------------- src/client/subagent-jobs.ts | 36 ++------- src/client/subagent-lineage.ts | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 98 deletions(-) create mode 100644 src/client/subagent-lineage.ts diff --git a/src/client/subagent-detect.ts b/src/client/subagent-detect.ts index f45b3ad..a8668ef 100644 --- a/src/client/subagent-detect.ts +++ b/src/client/subagent-detect.ts @@ -9,23 +9,20 @@ * - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage * totals (mirror of the official `indexSubagentDescendants` over the * plugin's own summary rows). + * + * The lineage walks themselves ({@link isSideThreadSummary}, {@link + * rootAncestor}, {@link countSubagentDescendants}) live in + * ./subagent-lineage.ts — the single shared walk implementation — and are + * re-exported below for their established import sites. */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarSubagentCatalog, } from '../context-types.ts' -import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' +import { countSubagentDescendants, isSideThreadSummary, rootAncestor } from './subagent-lineage.ts' -/** - * Side Chat threads ride the subagent origin (main-list hiding + the RPC - * ownership fence) but they are NOT subagent topology: they carry the - * durable 'Side: ' label and live as sidebar tabs. Excluding them here - * keeps the auto-open trigger and the Subagent page counts clean. - */ -export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { - return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) -} +export { countSubagentDescendants, isSideThreadSummary, rootAncestor } +export type { SubagentDescendantTotals } from './subagent-lineage.ts' /** Count the direct subagent children of one session (durable `origin` rows). */ export function directSubagentCount( @@ -40,28 +37,6 @@ export function directSubagentCount( return count } -/** - * The main agent of the current session's tree: walk the durable parent - * chain upward until the first non-subagent session. The Subagent page shows - * THIS root's full topology regardless of how deep the current selection is - * (a session whose row is still hydrating, or a broken chain, degrades to - * the session itself). - */ -export function rootAncestor( - byId: SidebarSessionList['byId'], - sessionId: string | undefined, -): string | undefined { - if (sessionId === undefined) return undefined - const seen = new Set() - let current: SidebarSessionSummary | undefined = byId[sessionId] - while (current !== undefined && current.origin === 'subagent' - && current.parentId !== undefined && !seen.has(current.id)) { - seen.add(current.id) - current = byId[current.parentId] - } - return current?.id ?? sessionId -} - /** * Collect every catalog branch (an entry with `hasChildren`) reachable from * the root — the set of catalogs the always-expanded topology consumes. @@ -101,37 +76,3 @@ export function detectNewDirectSubagent( return directSubagentCount(prev.byId, sessionId) === 0 && directSubagentCount(next.byId, sessionId) > 0 } - -/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ -export interface SubagentDescendantTotals { - count: number - runningCount: number -} - -/** - * Index every subagent descendant under each ancestor it reaches through an - * uninterrupted subagent-origin chain (same semantics as the official - * `indexSubagentDescendants`; cycles fail soft). - */ -export function countSubagentDescendants( - byId: SidebarSessionList['byId'], - sessionId: string, -): SubagentDescendantTotals { - const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } - for (const descendant of Object.values(byId)) { - if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue - const seen = new Set() - let current: SidebarSessionSummary | undefined = descendant - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - totals.count += 1 - if (descendant.running === true) totals.runningCount += 1 - break - } - current = byId[current.parentId] - } - } - return totals -} diff --git a/src/client/subagent-jobs.ts b/src/client/subagent-jobs.ts index 4962392..76af4a6 100644 --- a/src/client/subagent-jobs.ts +++ b/src/client/subagent-jobs.ts @@ -8,11 +8,11 @@ */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarJobStatus, SidebarJobView, } from '../context-types.ts' import type { CopyKey } from './locales.ts' +import { treeSessionIds } from './subagent-lineage.ts' /** One row of the jobs section: the job plus its owning session's title. */ export interface TreeJob { @@ -26,36 +26,10 @@ export function isJobLive(job: SidebarJobView): boolean { return job.status === 'running' || job.status === 'stopping' } -/** - * Every session id of the topology tree rooted at `rootId` (the root plus - * each session whose uninterrupted subagent-origin chain reaches it — same - * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). - * Sessions outside the tree (orphans, other trees) are excluded, so the - * jobs section never shows foreign work. - */ -export function treeSessionIds( - byId: SidebarSessionList['byId'], - rootId: string | undefined, -): Set { - const ids = new Set() - if (rootId === undefined) return ids - for (const summary of Object.values(byId)) { - const seen = new Set() - let current: SidebarSessionSummary | undefined = summary - let reachesRoot = false - while (current !== undefined && !seen.has(current.id)) { - seen.add(current.id) - if (current.id === rootId) { - reachesRoot = true - break - } - if (current.origin !== 'subagent' || current.parentId === undefined) break - current = byId[current.parentId] - } - if (reachesRoot) ids.add(summary.id) - } - return ids -} +// The lineage walk itself lives in ./subagent-lineage.ts (the single shared +// subagent-origin chain implementation); re-exported for the established +// import sites of this module. +export { treeSessionIds } /** * Whether a NEW background job appeared for one session between two diff --git a/src/client/subagent-lineage.ts b/src/client/subagent-lineage.ts new file mode 100644 index 0000000..d4cce66 --- /dev/null +++ b/src/client/subagent-lineage.ts @@ -0,0 +1,131 @@ +/** + * The shared subagent-lineage walks over the sessions list feed (structural + * mirror world — no runtime imports). Every public walk here answers a + * topology question by following the DURABLE subagent-origin chain upward + * (child → parent → …); one private generator ({@link subagentOriginChain}) + * is the single implementation of that walk, seen-set guarded so cycles fail + * soft (the walk just stops; nothing throws). + * + * subagent-detect.ts and subagent-jobs.ts re-export these for their + * established import sites — the auto-activation triggers and the Subagent + * page both consume them through the original module paths. + */ +import type { SidebarSessionList, SidebarSessionSummary } from '../context-types.ts' +import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' + +/** + * Side Chat threads ride the subagent origin (main-list hiding + the RPC + * ownership fence) but they are NOT subagent topology: they carry the + * durable 'Side: ' label and live as sidebar tabs. Excluding them here + * keeps the auto-open trigger and the Subagent page counts clean. + */ +export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { + return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) +} + +/** + * Yield `start`'s uninterrupted subagent-origin chain upward: each summary + * while it is a subagent with a known parent, then its parent row, and so + * on. A revisited id ends the walk (cycles fail soft); the row that BREAKS + * the chain — the first non-subagent ancestor, or a parent id with no row — + * is deliberately NOT yielded: callers observe it through where the walk + * stopped (the row after the last yielded one is `byId[last.parentId]`). + */ +function* subagentOriginChain( + byId: SidebarSessionList['byId'], + start: SidebarSessionSummary, +): Generator { + const seen = new Set() + let current: SidebarSessionSummary | undefined = start + while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) { + seen.add(current.id) + yield current + current = byId[current.parentId] + } +} + +/** + * The main agent of the current session's tree: walk the durable parent + * chain upward until the first non-subagent session. The Subagent page shows + * THIS root's full topology regardless of how deep the current selection is + * (a session whose row is still hydrating, or a broken chain, degrades to + * the session itself). + */ +export function rootAncestor( + byId: SidebarSessionList['byId'], + sessionId: string | undefined, +): string | undefined { + if (sessionId === undefined) return undefined + const start = byId[sessionId] + // A hydrating (or unknown) row degrades to the session itself. + if (start === undefined) return sessionId + let last: SidebarSessionSummary | undefined + for (const node of subagentOriginChain(byId, start)) last = node + if (last === undefined) return start.id + // The walk stopped one row past the last chain node: that parent row is + // the root (still missing from the feed → the session itself). Every + // yielded node carries a parent id (the generator's walk condition). + return byId[last.parentId!]?.id ?? sessionId +} + +/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ +export interface SubagentDescendantTotals { + count: number + runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain (same semantics as the official + * `indexSubagentDescendants`; cycles fail soft). + */ +export function countSubagentDescendants( + byId: SidebarSessionList['byId'], + sessionId: string, +): SubagentDescendantTotals { + const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } + for (const descendant of Object.values(byId)) { + if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue + for (const node of subagentOriginChain(byId, descendant)) { + if (node.parentId === sessionId) { + totals.count += 1 + if (descendant.running === true) totals.runningCount += 1 + break + } + } + } + return totals +} + +/** + * Every session id of the topology tree rooted at `rootId` (the root plus + * each session whose uninterrupted subagent-origin chain reaches it — same + * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). + * Sessions outside the tree (orphans, other trees) are excluded, so the + * jobs section never shows foreign work. + */ +export function treeSessionIds( + byId: SidebarSessionList['byId'], + rootId: string | undefined, +): Set { + const ids = new Set() + // A root with no row in the feed cannot be reached by walking: only real + // rows anchor tree membership (a parent pointer naming a hydrating root + // does not pull the orphaned branch into the tree). + if (rootId === undefined || byId[rootId] === undefined) return ids + for (const summary of Object.values(byId)) { + // The root row itself belongs to its own tree even though it is not a + // subagent (it is usually the main agent). + if (summary.id === rootId) { + ids.add(summary.id) + continue + } + for (const node of subagentOriginChain(byId, summary)) { + if (node.parentId === rootId) { + ids.add(summary.id) + break + } + } + } + return ids +}