fix(agent-team): consume shared projections without panel loads
This commit is contained in:
1 parent
3f07e34b5e
commit
2aa3a469db
32 files changed
+213
-210
No files matched your search
@@ -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 .agents/notes/implemented/feature/2026-08-05-agent-teams.md
|
||||
2026-08-05-agent-teams.md: 073b0ac745e5bb171987ff11b4bfba0a50a8087a
|
||||
2026-08-05-agent-teams.zh.md: 0fbb477429d10fbfbcbfb5437e977f597f3ce4c2
|
||||
2026-08-05-agent-teams.md: 6f271b18fbf7498dd60098ca4f1ef610a398700a
|
||||
2026-08-05-agent-teams.zh.md: 6e9a36b07167e661382b129079b6a98d88c0aa53
|
||||
@@ -56,9 +56,9 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
|
||||
|
||||
## Web projection
|
||||
|
||||
The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Current-session values use the standard `useProjection` hook; a teammate conversation reads its Lead through the shared cross-Session store.
|
||||
The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data, and changes to registered wire keys replace control baselines for connected clients.
|
||||
|
||||
The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; opening the panel requests other active members' baselines without opening their conversations, and no model is shown before a durable selection or request exists.
|
||||
The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it.
|
||||
|
||||
Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost.
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
|
||||
|
||||
## Web projection
|
||||
|
||||
Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。当前会话的值使用标准 `useProjection` 钩子;teammate 会话通过共享的跨 Session store 读取其 Lead。
|
||||
Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据,已注册传输 key 的变化会为已连接客户端替换控制基线。
|
||||
|
||||
传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;打开 panel 时请求其他 active 成员的基线,无需打开它们的会话,在持久选择或请求出现前不显示模型。
|
||||
传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。
|
||||
|
||||
发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-agent-team'
|
||||
@@ -82,10 +82,13 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
|
||||
it('displays agent-owned task changes through a read-only board without a refresh action', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
|
||||
const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
|
||||
onTestFinished(() => { projectionReads.mockRestore() })
|
||||
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
|
||||
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
await action.getByText('No shared tasks yet').waitFor()
|
||||
await action.getByText('lead').waitFor()
|
||||
expect(projectionReads).not.toHaveBeenCalled()
|
||||
|
||||
expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0)
|
||||
expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0)
|
||||
@@ -107,6 +110,7 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
|
||||
expect(projectionReads).not.toHaveBeenCalled()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await action.getByRole('button', { name: 'Close' }).click()
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce
|
||||
event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420
|
||||
event-producer-consumer.md: 3b259e4c32d5de56e6f97b214b5fd50ca3af8e6f
|
||||
event-producer-consumer.zh.md: 3f2d1527919b819aec8d61a8e932876196caa673
|
||||
@@ -55,6 +55,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -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 docs/subsystems/session-projection.md
|
||||
session-projection.md: 8580b94c4d7dd36f942ff10d77e13d2dc70b7e8b
|
||||
session-projection.zh.md: b98d348a36c51aab973bc3397ddbf744860245a4
|
||||
session-projection.md: 4cc68aab0e94d155b5f6736f61fdc2394e7bf622
|
||||
session-projection.zh.md: fd6e0428456a4893bcf15e7899baec4ea01f8295
|
||||
@@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se
|
||||
|
||||
Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md)
|
||||
|
||||
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
|
||||
<a id="session-projection-events"></a>
|
||||
|
||||
### `session-projection/*` events
|
||||
|
||||
<a id="session-projectiondefinitions-changed--emit"></a>
|
||||
|
||||
#### `session-projection/definitions-changed` — emit
|
||||
|
||||
The registered client-visible keys changed; carriers must replace their baselines.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The registered client-visible keys changed; carriers must replace their baselines.
|
||||
* @mode emit
|
||||
*/
|
||||
'session-projection/definitions-changed'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se
|
||||
|
||||
Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md)
|
||||
|
||||
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
|
||||
<a id="session-projection-events"></a>
|
||||
|
||||
### `session-projection/*` events
|
||||
|
||||
<a id="session-projectiondefinitions-changed--emit"></a>
|
||||
|
||||
#### `session-projection/definitions-changed` — emit
|
||||
|
||||
The registered client-visible keys changed; carriers must replace their baselines.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The registered client-visible keys changed; carriers must replace their baselines.
|
||||
* @mode emit
|
||||
*/
|
||||
'session-projection/definitions-changed'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -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/api/session-controller/README.md
|
||||
README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752
|
||||
README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272
|
||||
README.md: 08c68da01ab88e500970186cf799b33f9926289b
|
||||
README.zh.md: a9f5878dc8bcf7134dd0d658ec477fe21ecbcf1e
|
||||
@@ -45,6 +45,8 @@ Accepted prompts and observed running keep their Client display conversion acros
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
The control stream also sends a complete baseline when the registered client-visible projection keys change. Already-connected browsers receive late capability registration and removal without issuing a projection read.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
|
||||
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
客户端可见投影 key 的注册集合变化时,控制流也发送完整基线。已连接浏览器无需发起投影读取,即可收到能力的晚注册与移除。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export class SessionControlController {
|
||||
|
||||
/** @param ctx - Host context carrying live Agent and projection services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.on('session-projection/definitions-changed', () => {
|
||||
if (this.streams.size > 0) this.broadcast({ type: 'baseline', value: this.baseline() })
|
||||
})
|
||||
ctx.sessionProjections.onChanged((session, key, value, seq) => {
|
||||
this.broadcast({
|
||||
type: 'projection',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
@@ -17,6 +18,15 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/late-capability': number
|
||||
}
|
||||
interface SessionProjectionStateMap {
|
||||
'test/late-capability': number
|
||||
}
|
||||
}
|
||||
|
||||
const ownedContexts = new Set<Context>()
|
||||
afterEach(async () => {
|
||||
await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
|
||||
@@ -56,6 +66,47 @@ describe('Session control Inbox projection', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('updates connected clients when a wire capability is registered, shared, and removed', async () => {
|
||||
const { ctx, control, agent } = await harness()
|
||||
const empty = ctx.sessions.create(SessionId('empty-late-capability'))
|
||||
const abort = new AbortController()
|
||||
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
|
||||
try {
|
||||
await iterator.next()
|
||||
const unit = {
|
||||
key: 'test/late-capability' as const,
|
||||
stateSchema: z.number(),
|
||||
init: () => 7,
|
||||
apply: (state: number) => state,
|
||||
wire: { viewSchema: z.number(), view: (state: number) => state },
|
||||
stateVersion: 1,
|
||||
}
|
||||
const removeFirst = ctx.sessionProjections.register(unit)
|
||||
const removeSecond = ctx.sessionProjections.register(unit)
|
||||
removeFirst()
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: {
|
||||
type: 'baseline', value: { projections: {
|
||||
[agent.session.id]: { values: { 'test/late-capability': 7 } },
|
||||
[empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } },
|
||||
} },
|
||||
} })
|
||||
removeSecond()
|
||||
const removed = await iterator.next()
|
||||
if (removed.done || removed.value.type !== 'baseline') throw new Error('missing removal baseline')
|
||||
expect(removed.value.value.projections[agent.session.id]?.values).not.toHaveProperty('test/late-capability')
|
||||
ctx.sessionProjections.register(unit)
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: {
|
||||
type: 'baseline', value: { projections: {
|
||||
[agent.session.id]: { values: { 'test/late-capability': 7 } },
|
||||
[empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } },
|
||||
} },
|
||||
} })
|
||||
} finally {
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('projects both pending lists in baselines and live replacement frames', async () => {
|
||||
const { control, inbox } = await harness()
|
||||
const queued = message('queued')
|
||||
|
||||
@@ -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/experimental/agent-team/README.md
|
||||
README.md: 094e7a781ff01ebab1498b6ccd207814ae722dfb
|
||||
README.zh.md: e6be6e5b500c4ca635eeb52f82a12e5e07faaaab
|
||||
README.md: 7295ff0c15ba441ab07b786de1958acc218a2759
|
||||
README.zh.md: 07fe1a237a8c5dafa8e9640114d22aa76223f2e0
|
||||
@@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
|
||||
The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types.
|
||||
|
||||
The [Web UI](../client-ui-agent-team/README.md) owns projection baseline loading and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
|
||||
The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch
|
||||
|
||||
`agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。
|
||||
|
||||
[Web UI](../client-ui-agent-team/README.zh.md) 负责投影基线加载,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
|
||||
[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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/experimental/client-ui-agent-team/README.md
|
||||
README.md: 267c607f311500884e60d399a9e65ef0bb7fd868
|
||||
README.zh.md: 81d218379d3a5f370f7fbb6759b218cb3f92c47f
|
||||
README.md: c17f358332d3c61d1f6fa4dc5f4e4941d2dedec9
|
||||
README.zh.md: 66da22cd25a24aec1d9df72a2a7f85d126f379f3
|
||||
@@ -29,7 +29,9 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`]
|
||||
|
||||
### Inspect and navigate the roster
|
||||
|
||||
The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline once per connection, both in the Lead conversation and in a teammate conversation. A successful prior read is reused; a failed read shows a retry control alongside any last valid Team, and a reconnect reloads it. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
|
||||
The trigger shows the teammate count, and the panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward.
|
||||
|
||||
Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation.
|
||||
|
||||
### Inspect the task board
|
||||
|
||||
@@ -47,11 +49,11 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
|
||||
|
||||
The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations.
|
||||
|
||||
The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` reads its Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in both conversation types. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure.
|
||||
The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. `useSession` supplies the Lead identity; `useSessions` selects its Team and each member's model from the shared store. Each roster row selects its own running state. The only injected callback opens a teammate using the current and child Session ids. Switching conversations closes the panel and clears a navigation failure.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | Locale, projection-read, navigation, and slot registrations |
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | Locale, navigation, and slot registrations |
|
||||
| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state |
|
||||
| [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy |
|
||||
| [`src/index.ts`](src/index.ts) | Inert Host entry |
|
||||
@@ -83,10 +85,8 @@ No direct effect; the Team tools and ordinary conversation submission own any la
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability.
|
||||
- **Member baseline failures** — the panel shows read errors only for the Lead. Failed reads for other active members retry when the panel reopens or receives a roster or task update; persistent failures can issue repeated requests while the Team is active.
|
||||
- **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown.
|
||||
- **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent.
|
||||
- **Model availability** — a model appears only when the shared store has a durable selection or request for that member. Missing cold-cache values stay absent until normal Session loading or a live update supplies them.
|
||||
- **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox.
|
||||
- **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata.
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ kind: "package-reference"
|
||||
|
||||
### 检查并导航 roster
|
||||
|
||||
触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。无论在 Lead 会话还是 teammate 会话中,打开 panel 时都按每次连接一次的规则请求 Lead 的投影基线。之前读取成功则复用;读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
|
||||
触发按钮显示 teammate 数量,面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。
|
||||
|
||||
Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。
|
||||
|
||||
### 查看任务板
|
||||
|
||||
@@ -47,11 +49,11 @@ kind: "package-reference"
|
||||
|
||||
Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。
|
||||
|
||||
面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 读取其 Team 值。两类会话均通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。
|
||||
面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。`useSession` 提供 Lead 身份;`useSessions` 从共享 store 选择其 Team 及每个成员的模型。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与子 Session 的 id 打开 teammate。切换会话会关闭面板并清除导航失败。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | locale、投影读取、导航与 slot registration |
|
||||
| [`src/client/mount.ts`](src/client/mount.ts) | locale、导航与 slot registration |
|
||||
| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 |
|
||||
| [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 |
|
||||
| [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry |
|
||||
@@ -83,10 +85,8 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。
|
||||
- **成员基线读取失败** — 面板只显示 Lead 的读取错误。其他 active 成员的读取失败会在重新打开面板或收到 roster、任务更新时重试;Team 活跃期间,持续失败可能反复发出请求。
|
||||
- **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。
|
||||
- **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。
|
||||
- **模型可用性** — 仅当共享 store 包含该成员的持久选择或请求时才显示模型。冷缓存中缺少的值会保持缺失,直到正常 Session 加载或实时更新提供它们。
|
||||
- **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。
|
||||
- **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-experimental-agent-team/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import {
|
||||
IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot,
|
||||
IconCloseOutlineRegular, IconUserOutlineRegular, StateDot,
|
||||
useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -17,9 +17,8 @@ import css from './TeamAction.module.css'
|
||||
|
||||
/** Business actions injected by the browser plugin. */
|
||||
export interface TeamActionInjected {
|
||||
/** Read a Session's projection baseline once per connection, or retry a failed read. */
|
||||
loadProjections: (sessionId: SessionId) => void
|
||||
openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void
|
||||
/** Open a continuable child from the current conversation. */
|
||||
openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void
|
||||
}
|
||||
|
||||
/** Full props of the Team conversation-header action. */
|
||||
@@ -29,10 +28,6 @@ export type TeamActionProps =
|
||||
/** Durable lifecycle overlaid with the member Session's live turn activity. */
|
||||
type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed'
|
||||
|
||||
function failureText(error: { readonly code: string; readonly message: string }): string {
|
||||
return `${error.message} (${error.code})`
|
||||
}
|
||||
|
||||
function statusKey(status: TeamTask['status']): TeamKey {
|
||||
switch (status) {
|
||||
case 'pending': return 'status.pending'
|
||||
@@ -72,26 +67,21 @@ function taskDotState(task: TeamTask): StateDotState {
|
||||
}
|
||||
|
||||
type TeamMemberRowProps = Pick<TeamActionProps,
|
||||
'sessionId' | 'useProjection' | 'useSessions' | 'useSessionStatus' | 'openTeammate' | 't'
|
||||
'sessionId' | 'useSessions' | 'useSessionStatus' | 'openTeammate' | 't'
|
||||
> & {
|
||||
member: TeamMemberProjection
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
function TeamMemberRow({
|
||||
member, sessionId, useProjection, useSessions, useSessionStatus, openTeammate, onError, t,
|
||||
member, sessionId, useSessions, useSessionStatus, openTeammate, onError, t,
|
||||
}: TeamMemberRowProps) {
|
||||
const currentModel = useProjection('modelSelection', value =>
|
||||
member.id === sessionId ? value?.next?.model : undefined)
|
||||
const otherModel = useSessions(state => member.id === sessionId
|
||||
? undefined
|
||||
: state.projectionsBySession[member.id]?.values.modelSelection?.next?.model)
|
||||
const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model)
|
||||
const running = useSessionStatus(state => state.get(member.id)?.running)
|
||||
const summaryRunning = useSessions(state => state.byId[member.id]?.running)
|
||||
const status: MemberStatus = member.phase === 'active'
|
||||
? (running ?? summaryRunning) === true ? 'running' : 'inactive'
|
||||
: member.phase
|
||||
const model = member.id === sessionId ? currentModel : otherModel
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -101,7 +91,7 @@ function TeamMemberRow({
|
||||
title={member.role === 'teammate' ? t('open') : undefined}
|
||||
onClick={() => {
|
||||
try {
|
||||
openTeammate(sessionId, member)
|
||||
openTeammate(sessionId, member.id)
|
||||
} catch (reason) {
|
||||
onError(String(reason))
|
||||
}
|
||||
@@ -119,7 +109,7 @@ function TeamMemberRow({
|
||||
|
||||
/** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */
|
||||
export function TeamAction({
|
||||
sessionId, useSession, useProjection, useSessions, useSessionStatus, loadProjections, openTeammate, t,
|
||||
sessionId, useSession, useSessions, useSessionStatus, openTeammate, t,
|
||||
}: TeamActionProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -133,34 +123,15 @@ export function TeamAction({
|
||||
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
|
||||
|
||||
const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId
|
||||
const isLead = leadSessionId === sessionId
|
||||
const currentTeam = useProjection('agentTeam')
|
||||
const parentTeam = useSessions(state => isLead
|
||||
? undefined
|
||||
: state.projectionsBySession[leadSessionId]?.values.agentTeam)
|
||||
const readState = useSessions(state => state.projectionsBySession[leadSessionId]?.state)
|
||||
const projectionError = useSessions(state => state.projectionsBySession[leadSessionId]?.error ?? null)
|
||||
const team = isLead ? currentTeam : parentTeam
|
||||
const readError = readState === 'error' ? projectionError : null
|
||||
const ready = readState === 'ready'
|
||||
const members = team?.members
|
||||
const team = useSessions(state => state.projectionsBySession[leadSessionId]?.values.agentTeam)
|
||||
const opening = useSession(snapshot => snapshot.openState === 'loading')
|
||||
const listing = useSessions(state => state.phase === 'pending')
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
setError(null)
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadProjections(leadSessionId)
|
||||
}, [open, leadSessionId, loadProjections])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
for (const member of members ?? []) {
|
||||
if (member.id !== sessionId && member.id !== leadSessionId && member.phase === 'active') loadProjections(member.id)
|
||||
}
|
||||
}, [open, sessionId, leadSessionId, members, loadProjections])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && positioned) panelRef.current?.focus()
|
||||
}, [open, positioned])
|
||||
@@ -215,19 +186,10 @@ export function TeamAction({
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert"><StateDot state="error" />{error}</div>
|
||||
)}
|
||||
{readError !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<StateDot state="error" />
|
||||
<span className={css.spacer}>{failureText(readError)}</span>
|
||||
<button type="button" className={css.iconButton} aria-label={t('retry')} onClick={() => { loadProjections(leadSessionId) }}>
|
||||
<IconRefreshOutlineRegular size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{team === undefined && readError === null && (
|
||||
{team === undefined && (
|
||||
<div className={css.notice} role="status">
|
||||
<StateDot state={ready ? 'warning' : 'ongoing'} />
|
||||
{t(ready ? 'unavailable' : 'loading')}
|
||||
<StateDot state={opening || listing ? 'ongoing' : 'warning'} />
|
||||
{t(opening || listing ? 'loading' : 'unavailable')}
|
||||
</div>
|
||||
)}
|
||||
{team !== undefined && (
|
||||
@@ -243,7 +205,6 @@ export function TeamAction({
|
||||
key={member.id}
|
||||
member={member}
|
||||
sessionId={sessionId}
|
||||
useProjection={useProjection}
|
||||
useSessions={useSessions}
|
||||
useSessionStatus={useSessionStatus}
|
||||
openTeammate={openTeammate}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const NS = 'agent-team'
|
||||
export const zh = {
|
||||
trigger: 'Agent Team',
|
||||
close: '关闭',
|
||||
retry: '重试读取 Team',
|
||||
loading: '正在加载 Team…',
|
||||
unavailable: 'Team 暂不可用',
|
||||
failure: 'Team 持久记录无效:{message}',
|
||||
@@ -38,7 +37,6 @@ export type TeamKey = keyof typeof zh
|
||||
export const en = {
|
||||
trigger: 'Agent Team',
|
||||
close: 'Close',
|
||||
retry: 'Retry reading Team',
|
||||
loading: 'Loading Team…',
|
||||
unavailable: 'Team is unavailable',
|
||||
failure: 'Invalid persisted Team record: {message}',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Source-safe Agent Teams browser registration. */
|
||||
|
||||
import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -18,7 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required browser services for projection reads, navigation, slots, and localized copy. */
|
||||
/** Required browser services for navigation, slots, and localized copy. */
|
||||
export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale']
|
||||
|
||||
/**
|
||||
@@ -36,16 +35,12 @@ export function registerAgentTeamUi(ctx: ClientContext): void {
|
||||
}
|
||||
|
||||
const actions: TeamActionInjected = {
|
||||
loadProjections(sessionId: SessionId): void {
|
||||
void sessions.refreshProjections(sessionId)
|
||||
},
|
||||
openTeammate(sessionId: SessionId, member: TeamMemberProjection): void {
|
||||
if (member.role !== 'teammate') return
|
||||
openTeammate(sessionId: SessionId, childSessionId: SessionId): void {
|
||||
const parentSessionId = leadSessionId(sessionId)
|
||||
if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return
|
||||
ctx.uiWorkspace.openSession({
|
||||
parentSessionId,
|
||||
childSessionId: member.id,
|
||||
childSessionId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -3,15 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client'
|
||||
import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
const SESSION = 'team-session' as SessionId
|
||||
const CHILD = 'team-child' as SessionId
|
||||
const LEAD: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' }
|
||||
const WORKER: TeamMemberProjection = { id: CHILD, name: 'worker', role: 'teammate', phase: 'active' }
|
||||
|
||||
async function bench(options: { addressed?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -57,12 +54,11 @@ async function bench(options: { addressed?: boolean } = {}) {
|
||||
.find(candidate => candidate.component === TeamAction)
|
||||
const actions = (): TeamActionInjected => {
|
||||
const injected = entry()!.inject!()
|
||||
const { loadProjections, openTeammate } = injected
|
||||
if (typeof loadProjections !== 'function' || typeof openTeammate !== 'function') {
|
||||
const { openTeammate } = injected
|
||||
if (typeof openTeammate !== 'function') {
|
||||
throw new Error('Team header action lacks its injected callbacks')
|
||||
}
|
||||
return {
|
||||
loadProjections: loadProjections as TeamActionInjected['loadProjections'],
|
||||
openTeammate: openTeammate as TeamActionInjected['openTeammate'],
|
||||
}
|
||||
}
|
||||
@@ -88,10 +84,7 @@ describe('ui-team browser plugin', () => {
|
||||
const t = b.ctx.locale.bind('agent-team')
|
||||
expect(t('trigger')).toBe('Agent Team')
|
||||
|
||||
b.actions().loadProjections(SESSION)
|
||||
expect(b.navigation).toEqual([['refresh', SESSION]])
|
||||
b.actions().openTeammate(SESSION, LEAD)
|
||||
expect(b.navigation).toEqual([['refresh', SESSION]])
|
||||
expect(b.navigation).toEqual([])
|
||||
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
@@ -100,7 +93,7 @@ describe('ui-team browser plugin', () => {
|
||||
|
||||
it('opens a continuable teammate address without touching the parent catalog', async () => {
|
||||
const b = await bench()
|
||||
b.actions().openTeammate(SESSION, WORKER)
|
||||
b.actions().openTeammate(SESSION, CHILD)
|
||||
expect(b.navigation).toEqual([
|
||||
['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }],
|
||||
])
|
||||
@@ -108,7 +101,7 @@ describe('ui-team browser plugin', () => {
|
||||
|
||||
it('routes teammate navigation from an addressed teammate conversation back through its Lead', async () => {
|
||||
const b = await bench({ addressed: true })
|
||||
b.actions().openTeammate(CHILD, WORKER)
|
||||
b.actions().openTeammate(CHILD, CHILD)
|
||||
expect(b.navigation).toEqual([
|
||||
['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }],
|
||||
])
|
||||
@@ -117,7 +110,7 @@ describe('ui-team browser plugin', () => {
|
||||
it('does not open a teammate from a conversation outside the main view', async () => {
|
||||
const b = await bench()
|
||||
b.select('other-session' as SessionId)
|
||||
b.actions().openTeammate(SESSION, WORKER)
|
||||
b.actions().openTeammate(SESSION, CHILD)
|
||||
expect(b.navigation).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import type { SessionListState, SessionSnapshot, SessionSummary, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
@@ -80,7 +80,7 @@ function bench(options: {
|
||||
awaitingFirstTurn: false,
|
||||
})
|
||||
const useSessions = bindSnapshotSelector(sessions)
|
||||
const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() }
|
||||
const injected: TeamActionInjected = { openTeammate: vi.fn() }
|
||||
const props: TeamActionProps = {
|
||||
sessionId,
|
||||
useSession: bindSnapshotSelector(session),
|
||||
@@ -125,7 +125,6 @@ describe('TeamAction', () => {
|
||||
openPanel()
|
||||
expect(await screen.findByText('Implement runtime')).toBeTruthy()
|
||||
expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION)
|
||||
expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull()
|
||||
|
||||
setProjection(b.sessions, SESSION, {
|
||||
@@ -137,24 +136,6 @@ describe('TeamAction', () => {
|
||||
expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('reads the current Team and model through the standard projection hook', () => {
|
||||
const b = bench()
|
||||
const projected = createSnapshotStore({ ...team, tasks: [{ ...task, subject: 'Scoped Team task' }] })
|
||||
const useTeam = bindSnapshotSelector(projected)
|
||||
const useProjection = ((key: string, select?: (value: unknown) => unknown) => {
|
||||
const current = useTeam(value => value)
|
||||
const value = key === 'agentTeam' ? current : { next: { model: 'scoped-model' } }
|
||||
return select === undefined ? value : select(value)
|
||||
}) as UseProjection
|
||||
render(<TeamAction {...b.props} useProjection={useProjection} />)
|
||||
openPanel()
|
||||
expect(screen.getByText('Scoped Team task')).toBeTruthy()
|
||||
expect(screen.queryByText('Implement runtime')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /lead.*scoped-model/u })).toBeTruthy()
|
||||
act(() => { projected.set({ ...team, tasks: [{ ...task, subject: 'Updated scoped task' }] }) })
|
||||
expect(screen.getByText('Updated scoped task')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('overlays live Session status and the durable model selection on roster rows', () => {
|
||||
const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]])
|
||||
const b = bench({
|
||||
@@ -192,59 +173,41 @@ describe('TeamAction', () => {
|
||||
render(<TeamAction {...b.props} />)
|
||||
openPanel()
|
||||
expect(screen.getByText('Implement runtime')).toBeTruthy()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^worker/u }))
|
||||
expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker)
|
||||
expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, WORKER)
|
||||
})
|
||||
|
||||
it('loads the parent Team and retries failed parent baseline reads', () => {
|
||||
const b = bench({ sessionId: WORKER, parentSessionId: SESSION, projections: {} })
|
||||
render(<TeamAction {...b.props} />)
|
||||
openPanel()
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
|
||||
setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {} })
|
||||
expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.retry }))
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledTimes(2)
|
||||
|
||||
setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} })
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
|
||||
setProjection(b.sessions, SESSION, { members: [lead], tasks: [] })
|
||||
expect(screen.getByText(zh.empty)).toBeTruthy()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads projections for an already-open Lead and retries failed reads', () => {
|
||||
const b = bench({ projections: {}, openState: 'open' })
|
||||
render(<TeamAction {...b.props} />)
|
||||
expect(b.injected.loadProjections).not.toHaveBeenCalled()
|
||||
openPanel()
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION)
|
||||
|
||||
setProjectionSnapshot(b.sessions, SESSION, {
|
||||
state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {},
|
||||
it.each([false, true])('accepts shared baselines and late capability updates (teammate page: %s)', (addressed) => {
|
||||
const b = bench({
|
||||
...(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}),
|
||||
projections: {}, openState: 'loading',
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.retry }))
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledTimes(2)
|
||||
expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION)
|
||||
|
||||
setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} })
|
||||
render(<TeamAction {...b.props} />)
|
||||
openPanel()
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
act(() => { b.session.set({ ...b.session.getSnapshot(), openState: 'open' }) })
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.unavailable)
|
||||
setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: { agentTeam: team } })
|
||||
expect(screen.getByText('Implement runtime')).toBeTruthy()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} })
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.unavailable)
|
||||
setProjection(b.sessions, SESSION, { members: [lead], tasks: [] })
|
||||
expect(screen.getByText(zh.empty)).toBeTruthy()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.close }))
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledTimes(2)
|
||||
it('waits for the shared Session list and accepts cached projections without an explicit read', () => {
|
||||
const b = bench({ projections: {} })
|
||||
b.sessions.update((draft) => { draft.phase = 'pending' })
|
||||
render(<TeamAction {...b.props} />)
|
||||
openPanel()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledTimes(3)
|
||||
expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION)
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
act(() => { b.sessions.set({
|
||||
...b.sessions.getSnapshot(), phase: 'ready',
|
||||
projectionsBySession: { [SESSION]: { state: 'idle', error: null, values: { agentTeam: team } } },
|
||||
}) })
|
||||
expect(screen.getByText('Implement runtime')).toBeTruthy()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => {
|
||||
@@ -277,7 +240,6 @@ describe('TeamAction', () => {
|
||||
expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy()
|
||||
setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] })
|
||||
expect(screen.getByText('Updated parent task')).toBeTruthy()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION)
|
||||
})
|
||||
|
||||
it('shows capability absence after a successful read instead of loading forever', () => {
|
||||
@@ -288,39 +250,6 @@ describe('TeamAction', () => {
|
||||
expect(screen.getByRole('status').textContent).toBe('Team 暂不可用')
|
||||
})
|
||||
|
||||
it('keeps the last parent Team visible beside a retryable baseline failure', () => {
|
||||
const b = bench({ sessionId: WORKER, parentSessionId: SESSION })
|
||||
render(<TeamAction {...b.props} />)
|
||||
openPanel()
|
||||
setProjectionSnapshot(b.sessions, SESSION, {
|
||||
state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: { agentTeam: team },
|
||||
})
|
||||
expect(screen.getByText('Implement runtime')).toBeTruthy()
|
||||
expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.retry }))
|
||||
expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION)
|
||||
})
|
||||
|
||||
it('loads active members models without opening their conversations', () => {
|
||||
const nextMember = { ...worker, id: 'new-worker' as SessionId, name: 'new-worker', phase: 'provisioning' as const }
|
||||
const failed = { ...worker, id: 'failed-worker' as SessionId, name: 'failed-worker', phase: 'failed' as const }
|
||||
const b = bench()
|
||||
render(<TeamAction {...b.props} />)
|
||||
expect(b.injected.loadProjections).not.toHaveBeenCalled()
|
||||
openPanel()
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledWith(WORKER)
|
||||
setProjectionSnapshot(b.sessions, WORKER, {
|
||||
state: 'ready', error: null,
|
||||
values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'cold-worker-model' } } },
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /worker.*cold-worker-model/u })).toBeTruthy()
|
||||
setProjection(b.sessions, SESSION, { ...team, members: [...team.members, nextMember, failed] })
|
||||
expect(b.injected.loadProjections).not.toHaveBeenCalledWith(nextMember.id)
|
||||
expect(b.injected.loadProjections).not.toHaveBeenCalledWith(failed.id)
|
||||
setProjection(b.sessions, SESSION, { ...team, members: [...team.members, { ...nextMember, phase: 'active' }] })
|
||||
expect(b.injected.loadProjections).toHaveBeenCalledWith(nextMember.id)
|
||||
})
|
||||
|
||||
it('surfaces a Team projection failure beside the last valid state', () => {
|
||||
const b = bench({
|
||||
projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } },
|
||||
@@ -396,7 +325,6 @@ describe('TeamAction', () => {
|
||||
openPanel()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByRole('status').textContent).toBe(zh.loading)
|
||||
expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead')
|
||||
})
|
||||
|
||||
it('keeps panel interactions open and dismisses on outside pointer or Escape', () => {
|
||||
|
||||
@@ -3979,6 +3979,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.',
|
||||
parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }],
|
||||
},
|
||||
{
|
||||
name: 'session-projection/definitions-changed',
|
||||
mode: 'emit',
|
||||
signature: '\'session-projection/definitions-changed\'(): void',
|
||||
summary: 'The registered client-visible keys changed; carriers must replace their baselines.',
|
||||
description: 'The registered client-visible keys changed; carriers must replace their baselines.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
name: 'session-telemetry/record',
|
||||
mode: 'waterfall',
|
||||
|
||||
@@ -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/session/session-projection/README.md
|
||||
README.md: 2fa7ddc096e64e622c814f216a6c606de6ba1689
|
||||
README.zh.md: 565b331cd7e0f5190b6da4e54ed659d9da6d96fc
|
||||
README.md: d8262b498ac1ddcb6443d3e9db35c24ed0634ce9
|
||||
README.zh.md: 6be8a50733457a123aaa4436da7dde8008410cea
|
||||
@@ -57,6 +57,8 @@ const definition = {
|
||||
|
||||
`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views.
|
||||
|
||||
The registry emits `session-projection/definitions-changed` after a client-visible key is first registered or its last registration is removed. Carriers replace their baselines so connected clients observe capability changes without waiting for another Session event. Duplicate registrations and host-only units do not trigger this notification.
|
||||
|
||||
```text
|
||||
const dispose = ctx.sessionProjections.register(definition)
|
||||
const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
|
||||
|
||||
@@ -57,6 +57,8 @@ const definition = {
|
||||
|
||||
`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。
|
||||
|
||||
客户端可见 key 首次注册或最后一次注册被移除后,注册表发出 `session-projection/definitions-changed`。传输层替换基线,使已连接客户端无需等待下一个 Session 事件即可观察能力变化。重复注册与仅 Host 使用的投影单元不触发此通知。
|
||||
|
||||
```text
|
||||
const dispose = ctx.sessionProjections.register(definition)
|
||||
const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
|
||||
|
||||
@@ -31,6 +31,13 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
sessionProjections: SessionProjectionRegistry
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* The registered client-visible keys changed; carriers must replace their baselines.
|
||||
* @mode emit
|
||||
*/
|
||||
'session-projection/definitions-changed'(): void
|
||||
}
|
||||
}
|
||||
|
||||
import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts'
|
||||
@@ -286,8 +293,12 @@ export class SessionProjectionRegistry extends Service {
|
||||
/* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */
|
||||
if (live === undefined) return
|
||||
live.refs -= 1
|
||||
if (live.refs === 0) this.registrations.delete(key)
|
||||
if (live.refs === 0) {
|
||||
this.registrations.delete(key)
|
||||
if (live.def.wire !== undefined) this.ctx.emit('session-projection/definitions-changed')
|
||||
}
|
||||
}
|
||||
if (existing === undefined && erased.wire !== undefined) this.ctx.emit('session-projection/definitions-changed')
|
||||
}.bind(this), 'sessionProjections.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'session-projection': 'session-projection.md',
|
||||
'app-boot': 'boot.md',
|
||||
hmr: 'boot.md',
|
||||
'plugin-manager': 'boot.md',
|
||||
|
||||
Reference in new issue
Block a user