Merge pull request #1159 from deepseek-harness/codex/subagent-token-duration
Fix subagent active duration tracking
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df
|
||||
README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77
|
||||
README.md: 82f1bc95a6128245f88f01a0de0849494cb98359
|
||||
README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
@@ -21,6 +24,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
|
||||
@@ -846,10 +846,14 @@ export class SessionManager {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
return {
|
||||
...summary,
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
})
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
@@ -860,6 +864,7 @@ export class SessionManager {
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
&& prev.projectionValues === entry.projectionValues
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Channel {
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
@@ -98,6 +99,19 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
*/
|
||||
values(): Readonly<Partial<SessionProjectionMap>> {
|
||||
if (this.valuesCache === undefined) {
|
||||
this.valuesCache = Object.freeze(Object.fromEntries(
|
||||
[...this.rows].map(([key, row]) => [key, row.value]),
|
||||
))
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
@@ -160,6 +174,7 @@ export class ProjectionValueStore {
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.valuesCache = undefined
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
@@ -57,6 +58,8 @@ export interface SessionSummary {
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -613,6 +616,9 @@ export class SessionsService implements ISessions {
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
|
||||
@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
|
||||
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
const empty = store.values()
|
||||
expect(store.values()).toBe(empty)
|
||||
store.apply('test/marks', { marks: ['a'] }, 1)
|
||||
const populated = store.values()
|
||||
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(populated).not.toBe(empty)
|
||||
expect(store.values()).toBe(populated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 2,
|
||||
values: { 'test/marks': { marks: ['baseline'] } },
|
||||
},
|
||||
}],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
const baseline = manager.getListSnapshot().items[0]?.projectionValues
|
||||
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: {
|
||||
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
} as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
.toEqual({ 'test/marks': { marks: ['live'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
|
||||
README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
|
||||
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2
|
||||
README.md: c2a005a9c9716d246bb4299ab1654314da21643a
|
||||
README.zh.md: 4fb42b39fd7810f5d60e1b8a00bd9ad7d8752feb
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
|
||||
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
|
||||
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
|
||||
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及活跃轮次耗时。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
|
||||
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
|
||||
|
||||
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录)
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -59,6 +60,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -182,6 +182,8 @@
|
||||
.time {
|
||||
flex: none;
|
||||
margin-top: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.children {
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
|
||||
SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress,
|
||||
SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import css from './SubagentCatalogAction.module.css'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
@@ -57,23 +59,95 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
||||
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
|
||||
}
|
||||
|
||||
/** Compact trailing activity time for a catalog row. */
|
||||
function relativeTime(
|
||||
updatedAt: number | undefined,
|
||||
/** Exact whole-second active-turn duration for one catalog row. */
|
||||
function activityDuration(
|
||||
summary: SessionSummary | undefined,
|
||||
activity: 'running' | 'inactive',
|
||||
now: number,
|
||||
t: TranslateNS<typeof NS>,
|
||||
): string | undefined {
|
||||
if (updatedAt === undefined) return undefined
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < minute) return t('time.justNow')
|
||||
if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) })
|
||||
if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) })
|
||||
if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) })
|
||||
if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) })
|
||||
return t('time.years', { n: Math.floor(diff / (365 * day)) })
|
||||
): number | undefined {
|
||||
if (summary === undefined) return undefined
|
||||
const timing: SessionProjectionMap['subagentTiming'] | undefined
|
||||
= summary.projectionValues?.subagentTiming
|
||||
if (timing === undefined) return undefined
|
||||
if (timing.active === undefined) return timing.settledMs
|
||||
const end = activity === 'running'
|
||||
? now
|
||||
: timing.active.through
|
||||
return timing.settledMs + Math.max(0, end - timing.active.since)
|
||||
}
|
||||
|
||||
interface DurationParts {
|
||||
seconds: number
|
||||
minutes: number
|
||||
hours: number
|
||||
days: number
|
||||
totalMinutes: number
|
||||
totalHours: number
|
||||
}
|
||||
|
||||
function splitDuration(ms: number): DurationParts {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
return {
|
||||
seconds: totalSeconds % 60,
|
||||
minutes: totalMinutes % 60,
|
||||
hours: totalHours % 24,
|
||||
days: Math.floor(totalHours / 24),
|
||||
totalMinutes,
|
||||
totalHours,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a duration with decreasing visual precision at larger scales. */
|
||||
function formatDuration(ms: number, t: TranslateNS<typeof NS>): string {
|
||||
const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms)
|
||||
if (days >= 365) {
|
||||
const years = Math.floor(days / 365)
|
||||
const months = Math.floor((days % 365) / 30)
|
||||
return months === 0
|
||||
? t('duration.years', { years })
|
||||
: t('duration.yearsMonths', { years, months })
|
||||
}
|
||||
if (days >= 30) {
|
||||
const months = Math.floor(days / 30)
|
||||
const remainingDays = days % 30
|
||||
return remainingDays === 0
|
||||
? t('duration.months', { months })
|
||||
: t('duration.monthsDays', { months, days: remainingDays })
|
||||
}
|
||||
if (days > 0) {
|
||||
return hours === 0
|
||||
? t('duration.days', { days })
|
||||
: t('duration.daysHours', { days, hours })
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
return t('duration.hours', {
|
||||
hours: totalHours,
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
if (totalMinutes > 0) {
|
||||
return t('duration.minutes', {
|
||||
minutes: totalMinutes,
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/** Preserve exact whole seconds for hover and accessible naming. */
|
||||
function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
|
||||
const { seconds, minutes, hours, days } = splitDuration(ms)
|
||||
return days === 0
|
||||
? formatDuration(ms, t)
|
||||
: t('duration.exactDays', {
|
||||
days,
|
||||
hours: String(hours).padStart(2, '0'),
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||
@@ -201,7 +275,17 @@ function CatalogRows({
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
const time = relativeTime(summary?.updatedAt, now, t)
|
||||
const durationMs = activityDuration(
|
||||
summary,
|
||||
entry.activity,
|
||||
now,
|
||||
)
|
||||
const duration = durationMs === undefined
|
||||
? undefined
|
||||
: {
|
||||
compact: formatDuration(durationMs, t),
|
||||
exact: formatExactDuration(durationMs, t),
|
||||
}
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
||||
@@ -233,7 +317,9 @@ function CatalogRows({
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
aria-label={[label, secondary, duration?.exact]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
@@ -258,7 +344,14 @@ function CatalogRows({
|
||||
<span className={css.label}>{label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
{duration !== undefined && (
|
||||
<span
|
||||
className={css.time}
|
||||
title={t('duration.exactTitle', { duration: duration.exact })}
|
||||
>
|
||||
{duration.compact}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && !knownLeaf && (
|
||||
@@ -313,6 +406,7 @@ export function SubagentCatalogAction({
|
||||
const summaries = useSessions(state => state.byId)
|
||||
const catalog = catalogs[sessionId]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -355,7 +449,10 @@ export function SubagentCatalogAction({
|
||||
|
||||
const changeOpen = (next: boolean, restoreFocus = false): void => {
|
||||
setOpen(next)
|
||||
if (next) observeCatalog(sessionId, true)
|
||||
if (next) {
|
||||
setNow(Date.now())
|
||||
observeCatalog(sessionId, true)
|
||||
}
|
||||
else closeAllCatalogs()
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
@@ -395,6 +492,12 @@ export function SubagentCatalogAction({
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !descendants.running) return
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open, descendants.running])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpenRef.current(parentSessionId, false)
|
||||
@@ -471,7 +574,7 @@ export function SubagentCatalogAction({
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
now={Date.now()}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
|
||||
@@ -8,12 +8,17 @@ export const zh = {
|
||||
'diagnostic.corrupt': '会话记录损坏',
|
||||
'diagnostic.unsupported': '子代理记录版本不受支持',
|
||||
'diagnostic.unavailable': '会话记录暂不可用',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutes': '{n}分钟',
|
||||
'time.hours': '{n}小时',
|
||||
'time.days': '{n}天',
|
||||
'time.months': '{n}个月',
|
||||
'time.years': '{n}年',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
|
||||
'duration.days': '{days}天',
|
||||
'duration.daysHours': '{days}天{hours}小时',
|
||||
'duration.months': '约{months}个月',
|
||||
'duration.monthsDays': '约{months}个月{days}天',
|
||||
'duration.years': '约{years}年',
|
||||
'duration.yearsMonths': '约{years}年{months}个月',
|
||||
'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒',
|
||||
'duration.exactTitle': '总活跃耗时:{duration}',
|
||||
'loading.label': '正在加载子代理…',
|
||||
'loading.aria': '正在加载子代理',
|
||||
'load.error': '无法加载子代理',
|
||||
@@ -40,12 +45,17 @@ export const en: Record<SubagentKey, string> = {
|
||||
'diagnostic.corrupt': 'corrupted session record',
|
||||
'diagnostic.unsupported': 'unsupported subagent record version',
|
||||
'diagnostic.unavailable': 'session record temporarily unavailable',
|
||||
'time.justNow': 'just now',
|
||||
'time.minutes': '{n}m',
|
||||
'time.hours': '{n}h',
|
||||
'time.days': '{n}d',
|
||||
'time.months': '{n}mo',
|
||||
'time.years': '{n}y',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m {seconds}s',
|
||||
'duration.days': '{days}d',
|
||||
'duration.daysHours': '{days}d {hours}h',
|
||||
'duration.months': '~{months}mo',
|
||||
'duration.monthsDays': '~{months}mo {days}d',
|
||||
'duration.years': '~{years}y',
|
||||
'duration.yearsMonths': '~{years}y {months}mo',
|
||||
'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s',
|
||||
'duration.exactTitle': 'Total active duration: {duration}',
|
||||
'loading.label': 'Loading subagents…',
|
||||
'loading.aria': 'Loading subagents',
|
||||
'load.error': 'Unable to load subagents',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -239,42 +240,71 @@ describe('SubagentCatalogAction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders compact activity times across every unit and clamps future timestamps', () => {
|
||||
it('ticks active duration by seconds and freezes inactive rows', async () => {
|
||||
const now = 2_000_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
const rows = [
|
||||
['future', now + minute],
|
||||
['minutes', now - 2 * minute],
|
||||
['hours', now - 2 * hour],
|
||||
['days', now - 2 * day],
|
||||
['months', now - 60 * day],
|
||||
['years', now - 2 * 365 * day],
|
||||
['running', 'running', 65_000, now - 5_000, now - 1_000, now],
|
||||
['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000],
|
||||
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000],
|
||||
['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now],
|
||||
['whole-day', 'inactive', day, undefined, undefined, now],
|
||||
['months', 'inactive', 192 * day, undefined, undefined, now],
|
||||
['whole-month', 'inactive', 30 * day, undefined, undefined, now],
|
||||
['years', 'inactive', 832 * day, undefined, undefined, now],
|
||||
['whole-year', 'inactive', 365 * day, undefined, undefined, now],
|
||||
] as const
|
||||
const entries = rows.map(([id]) => ({
|
||||
const entries = rows.map(([id, activity]) => ({
|
||||
kind: 'child' as const,
|
||||
id: id as SessionId,
|
||||
mode: 'continuable' as const,
|
||||
label: id,
|
||||
activity: 'inactive' as const,
|
||||
activity,
|
||||
hasChildren: false,
|
||||
}))
|
||||
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
|
||||
id,
|
||||
summary(id as SessionId, updatedAt),
|
||||
])) as Record<SessionId, SessionSummary>
|
||||
const summaries = Object.fromEntries(rows.map(([
|
||||
id, activity, settledMs, activeSince, activeThrough, updatedAt,
|
||||
]) => {
|
||||
const childId = id as SessionId
|
||||
return [id, {
|
||||
...summary(childId, updatedAt),
|
||||
parentId: PARENT,
|
||||
origin: 'subagent' as const,
|
||||
running: activity === 'running',
|
||||
projectionValues: {
|
||||
subagentTiming: {
|
||||
settledMs,
|
||||
...(activeSince === undefined || activeThrough === undefined
|
||||
? {}
|
||||
: { active: { since: activeSince, through: activeThrough } }),
|
||||
},
|
||||
},
|
||||
}]
|
||||
})) as Record<SessionId, SessionSummary>
|
||||
const input = props(catalog({ entries }), {}, summaries)
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ }))
|
||||
|
||||
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /running.*1分10秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy()
|
||||
expect(screen.getByText('12天5小时').getAttribute('title'))
|
||||
.toBe('总活跃耗时:12天05小时06分07秒')
|
||||
expect(screen.getByText('1天')).toBeTruthy()
|
||||
expect(screen.getByText('约6个月12天')).toBeTruthy()
|
||||
expect(screen.getByText('约1个月')).toBeTruthy()
|
||||
expect(screen.getByText('约2年3个月')).toBeTruthy()
|
||||
expect(screen.getByText('约1年')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(screen.getByRole('treeitem', { name: /running.*1分11秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe
|
||||
README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953
|
||||
README.md: e54f0b98ec3649cec428a47026e6657a9749608b
|
||||
README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4
|
||||
|
||||
@@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
@@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
@@ -15,17 +15,25 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
@@ -35,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -46,6 +55,9 @@
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -59,6 +71,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
7
packages/subagent/subagent/src/client.ts
Normal file
7
packages/subagent/subagent/src/client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Browser-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
@@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -117,6 +118,7 @@ export type {
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -186,6 +188,9 @@ export class SubagentService extends Service {
|
||||
if (this.continuations === manager) this.continuations = undefined
|
||||
}, 'subagents.continuationBinding()')
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
packages/subagent/subagent/src/projection-types.ts
Normal file
25
packages/subagent/subagent/src/projection-types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Pure client-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection-types
|
||||
*/
|
||||
|
||||
/** Durable active-turn timing for one descriptor-backed child session. */
|
||||
export interface SubagentTimingProjection {
|
||||
/** Milliseconds accumulated across completed turns after the child's own descriptor. */
|
||||
settledMs: number
|
||||
/** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */
|
||||
active?: {
|
||||
/** Start of the open turn. */
|
||||
since: number
|
||||
/** Latest event time folded into this projection cut. */
|
||||
through: number
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
}
|
||||
}
|
||||
82
packages/subagent/subagent/src/projection.ts
Normal file
82
packages/subagent/subagent/src/projection.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure session projection for subagent active-turn duration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
interface TimingState {
|
||||
/** Milliseconds accumulated across completed post-descriptor turns. */
|
||||
settledMs: number
|
||||
/** Current open interval kept paired inside the fold. */
|
||||
active?: { since: number; through: number }
|
||||
/** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
|
||||
pendingTurnStart?: number
|
||||
/** Whether the fold has crossed a descriptor in this logical log. */
|
||||
descriptorSeen: boolean
|
||||
}
|
||||
|
||||
// Zod's optional output includes explicit `undefined`; with
|
||||
// exactOptionalPropertyTypes the public interface permits omission only.
|
||||
const projectionSchema = z.object({
|
||||
settledMs: z.number().int().nonnegative(),
|
||||
active: z.object({
|
||||
since: z.number().int().nonnegative(),
|
||||
through: z.number().int().nonnegative(),
|
||||
}).strict().optional(),
|
||||
}).strict() as unknown as z.ZodType<SubagentTimingProjection>
|
||||
|
||||
/**
|
||||
* Fold turn boundaries around the child's own durable descriptor.
|
||||
*
|
||||
* A fork seed may contain an ancestor descriptor and completed turns. Every
|
||||
* descriptor therefore resets the accumulated state; the healthy catalog
|
||||
* admits only a child with exactly one descriptor in its own suffix, making
|
||||
* the final reset the child's authoritative timing origin.
|
||||
*/
|
||||
export const subagentTimingProjectionDefinition:
|
||||
ProjectionDefinition<'subagentTiming', TimingState> = {
|
||||
key: 'subagentTiming',
|
||||
schema: projectionSchema,
|
||||
init: () => ({ descriptorSeen: false, settledMs: 0 }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'turn/start') {
|
||||
return state.descriptorSeen
|
||||
? { ...state, active: { since: event.time, through: event.time } }
|
||||
: { ...state, pendingTurnStart: event.time }
|
||||
}
|
||||
if (event.type === 'subagent/descriptor') {
|
||||
const activeSince = state.active?.since ?? state.pendingTurnStart
|
||||
return {
|
||||
descriptorSeen: true,
|
||||
settledMs: 0,
|
||||
...(activeSince === undefined
|
||||
? {}
|
||||
: { active: { since: activeSince, through: event.time } }),
|
||||
}
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
if (!state.descriptorSeen) {
|
||||
if (state.pendingTurnStart === undefined) return state
|
||||
const { pendingTurnStart: _closed, ...next } = state
|
||||
return next
|
||||
}
|
||||
if (state.active === undefined) return state
|
||||
const { active, ...rest } = state
|
||||
return {
|
||||
...rest,
|
||||
settledMs: state.settledMs + Math.max(0, event.time - active.since),
|
||||
}
|
||||
}
|
||||
if (state.active === undefined) return state
|
||||
return { ...state, active: { ...state.active, through: event.time } }
|
||||
},
|
||||
view: state => ({
|
||||
settledMs: state.settledMs,
|
||||
...(state.active === undefined ? {} : { active: state.active }),
|
||||
}),
|
||||
stateVersion: 2,
|
||||
}
|
||||
80
packages/subagent/subagent/tests/timing-projection.spec.ts
Normal file
80
packages/subagent/subagent/tests/timing-projection.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '../src/index.ts'
|
||||
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
|
||||
|
||||
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
|
||||
return { type, seq, time, data: {} } as SessionEvent
|
||||
}
|
||||
|
||||
function fold(events: SessionEvent[]) {
|
||||
let state = subagentTimingProjectionDefinition.init()
|
||||
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
|
||||
return subagentTimingProjectionDefinition.view(state)
|
||||
}
|
||||
|
||||
describe('subagent timing projection', () => {
|
||||
it('registers with the optional session projection registry', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toEqual({ settledMs: 0 })
|
||||
await serviceFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('subagent/descriptor', 1, 110),
|
||||
event('turn/end', 2, 300),
|
||||
event('turn/start', 3, 1_000),
|
||||
event('subagent/descriptor', 4, 1_100),
|
||||
event('turn/end', 5, 4_100),
|
||||
event('turn/start', 6, 10_000),
|
||||
event('turn/end', 7, 12_000),
|
||||
])).toEqual({ settledMs: 5_100 })
|
||||
})
|
||||
|
||||
it('exposes an open turn start and never subtracts time for reversed boundaries', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 1_000),
|
||||
event('subagent/descriptor', 1, 1_100),
|
||||
event('turn/end', 2, 900),
|
||||
event('turn/start', 3, 2_000),
|
||||
event('assistant/chunk', 4, 2_500),
|
||||
])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } })
|
||||
})
|
||||
|
||||
it('ignores completed pre-descriptor turns and unrelated events', () => {
|
||||
const initial = subagentTimingProjectionDefinition.init()
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('assistant/chunk', 0, 1),
|
||||
)).toBe(initial)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('turn/end', 1, 2),
|
||||
)).toBe(initial)
|
||||
const descriptor = subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('subagent/descriptor', 2, 3),
|
||||
)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
descriptor,
|
||||
event('turn/end', 3, 4),
|
||||
)).toBe(descriptor)
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('turn/end', 1, 200),
|
||||
event('subagent/descriptor', 2, 300),
|
||||
])).toEqual({ settledMs: 0 })
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user