diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml index 3013474208..c5eb350c92 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -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-07-23-web-permission-and-approval.md -2026-07-23-web-permission-and-approval.md: 37fd3a04603747572f538e57a69441b32a51c947 -2026-07-23-web-permission-and-approval.zh.md: f895f66d052481962b91d6f0cb5bd7ebeeab06d0 +2026-07-23-web-permission-and-approval.md: 1c5d9f0a7d470719648251925523b1039b737969 +2026-07-23-web-permission-and-approval.zh.md: 1b9df5209db3a9482ba684382958da6b0125c628 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md index 37fd3a0460..1c5d9f0a7d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -16,7 +16,7 @@ The web host composes the same sandboxed product path as the acp-agent compositi The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. -Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-question, including the `plan-review` decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies plan-review intent separately, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable `PendingWait`; tracking clears per connection generation so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-question, including the `plan-review` decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies only requests satisfying the plan-review composer's binary rendering constraints as plan reviews, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable `PendingWait`; tracking clears at every connection-generation death so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md index f895f66d05..1b9df5209d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -16,7 +16,7 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l 权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 -在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它单独分类 plan-review intent,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪按连接代次清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪会在每个连接代次结束时清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 ## 曾考虑的替代方案 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..0c4250f25c 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: b8dec02d94f6e0afb3a19ca9041b73b5418dc92d +README.zh.md: f01ef6fd585bb5700ca45b06ca9a622b53b4cab0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..b8dec02d94 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The loop reports every established generation and each generation invalidation before retry, while its coarse `connected`/`reconnecting` state notifications remain deduplicated for presentation. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..f01ef6fd58 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。循环会分别报告每个已建立的连接代次,并在每个代次失效时、重试之前报告断连;面向呈现的粗粒度 `connected`/`reconnecting` 状态通知仍会去重。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..cacbff65f8 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -46,6 +46,9 @@ export interface ConnectionSinks { onHostEnvelope?: (envelope: RpcRequest) => void /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ onConnected?: () => void + /** After every connection generation is invalidated, before any next-generation frame can arrive. + * Unlike coarse state changes, consecutive failed generations each fire this sink. */ + onDisconnected?: () => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -115,38 +118,47 @@ export class ConnectionController { new Promise((resolve) => { hostOpened = resolve }), ]) - const failed = new Promise((resolve) => { - const settle = (): void => { - if (gen === this.generation && !ac.signal.aborted) ac.abort() - resolve() - } - void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) - void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) - }) + /* v8 ignore next -- initializer placeholder: the Promise executor + * below runs synchronously and replaces it before settle can call it. */ + let signalFailure = (): void => {} + const failed = new Promise((resolve) => { signalFailure = resolve }) + const settle = (): void => { + if (gen === this.generation && !ac.signal.aborted) ac.abort() + signalFailure() + } + void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle, ac.signal) + void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle, ac.signal) + const timeout = new AbortController() try { // Strict readiness handshake (audit C2): describe proves unary reachability, onOpen // proves each SSE transport is established (response headers in, before any frame) — // only then may onConnected fire, so the resync it triggers cannot outrun the // subscribed baseline. The timeout guards against a carrier that never fires onOpen - // (see ConnectionConfig.streamOpenTimeoutMs). - const timeout = new AbortController() - await Promise.all([ - this.api.host.describe({}), - Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), + // (see ConnectionConfig.streamOpenTimeoutMs). Stream death also ends a handshake + // whose unary describe call has not settled. + const ready = await Promise.race([ + Promise.all([ + this.api.host.describe({}), + Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), + ]).then(() => true), + failed.then(() => false), ]) - timeout.abort() - if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') + if (!ready || ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 this.emitState('connected') this.callSink(this.sinks.onConnected) } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. if (!ac.signal.aborted) ac.abort() + } finally { + timeout.abort() } await failed if (!this.isRunning()) return + this.callSink(this.sinks.onDisconnected) + if (!this.isRunning()) return this.emitState('reconnecting') this.attempt += 1 console.warn(`[web-runtime] connection lost, retry #${this.attempt}`) @@ -166,9 +178,13 @@ export class ConnectionController { stream: AsyncIterable>, sink: ((envelope: RpcRequest) => void) | undefined, onEnd: () => void, + signal: AbortSignal, ): Promise { try { for await (const envelope of stream) { + // The sibling stream may have ended while this read was settling. Do + // not let a late dead-generation frame cross the disconnect boundary. + if (signal.aborted) break if (envelope.payload.type === 'stream/error') break if (sink !== undefined) this.callSink(() => { sink(envelope) }) } diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..e4d84ffef0 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' +import type { IApiClient, SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' import { FakeApiClient, deferred, ok } from './fake-api.ts' @@ -83,6 +83,27 @@ describe('connection lifecycle', () => { } }) + it('leaves readiness when a stream dies while describe is still pending', async () => { + const api = new FakeApiClient() + const describe = deferred>>() + api.onDescribe = () => describe.promise + let disconnected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { + onDisconnected: () => { disconnected++ }, + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) + api.failStreams(new Error('stream torn during readiness')) + await vi.waitFor(() => { expect(disconnected).toBe(1) }) + } finally { + controller.stop() + describe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + warnSpy.mockRestore() + } + }) + it('converges stream/error frames into reconnect instead of dispatching them', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] @@ -159,6 +180,65 @@ describe('connection lifecycle', () => { } }) + it('reports generation death promptly and drops a late sibling-stream frame', async () => { + const base = new FakeApiClient() + const baseEvents = base.events + const failMux = deferred() + const releaseHostClose = deferred() + let muxEnded = false + const api: IApiClient = base + Object.defineProperty(api, 'events', { + value: { + mux: async function* (_payload: unknown, _signal: AbortSignal, onOpen?: () => void) { + onOpen?.() + await failMux.promise + muxEnded = true + throw new Error('mux lost') + }, + host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => { + const source = baseEvents.host({}, signal, onOpen) + return { + async *[Symbol.asyncIterator]() { + for await (const envelope of source) yield envelope + await releaseHostClose.promise + yield { + rpcId: 'late-host' as never, + payload: { type: 'host/session-status', sessionId: SID, running: true }, + } + }, + } + }, + }, + }) + let connected = 0 + let disconnected = 0 + const hostFrames: string[] = [] + let stop = (): void => {} + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { + onConnected: () => { connected++ }, + onDisconnected: () => { + disconnected++ + stop() + }, + onHostEnvelope: envelope => hostFrames.push(envelope.payload.type), + }, FAST) + stop = () => { controller.stop() } + controller.start() + try { + await vi.waitFor(() => { expect(connected).toBe(1) }) + failMux.resolve(undefined) + await vi.waitFor(() => { expect(muxEnded).toBe(true) }) + await vi.waitFor(() => { expect(disconnected).toBe(1) }) + releaseHostClose.resolve(undefined) + await Promise.resolve() + expect(hostFrames).toEqual([]) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('emits deduplicated connected/reconnecting state transitions', async () => { const api = new FakeApiClient() const states: ConnectionState[] = [] @@ -181,7 +261,7 @@ describe('connection lifecycle', () => { } }) - it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { + it('deduplicates reconnecting state while reporting every failed generation', async () => { const api = new FakeApiClient() const gate = deferred>>() let describeCalls = 0 @@ -191,14 +271,17 @@ describe('connection lifecycle', () => { } const states: ConnectionState[] = [] let connected = 0 + let disconnected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const controller = new ConnectionController(api, { onConnected: () => { connected++ }, + onDisconnected: () => { disconnected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) + expect(disconnected).toBe(2) // generation ownership is not deduplicated with UI state gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f5a71ec95a..4a22a0d7a6 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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: f24ab9f9c056b6c1136676146082e7aaaa23377f -README.zh.md: af3d5bb42e62818b969c9e0de814c1bcff4d9f37 +README.md: 1d474c63f25f4aa25dc20c842f1968ea00ca3773 +README.zh.md: 6ed5d74042901f667fc3d6bccbb2304d1aab00a8 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f24ab9f9c0..1d474c63f2 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -8,7 +8,7 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. -`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while a question carrying the `plan-review` intent keeps that distinct status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. +`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: every generation death clears it, and mux-open replay restores only requests that remain pending. `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index af3d5bb42e..6ed5d74042 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -8,7 +8,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 -`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;带有 `plan-review` intent 的问题会保留这一独立状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 +`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:每个代次结束时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 2336ec9d9c..9c00bd805a 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -219,17 +219,14 @@ export function apply(ctx: Context): void { console.error('[web-runtime] history reconnect failed:', error) } }, - onStateChange: (state) => { - // Generation death fires before any next-generation frame can arrive - // (reconnect replays flow from stream open, ahead of onConnected): - // the only safe moment to drop generation-scoped interaction state. - if (state === 'reconnecting') { - sessions.handleDisconnected() - try { - sessionHistory.handleDisconnected() - } catch (error) { - console.error('[web-runtime] history disconnect failed:', error) - } + onDisconnected: () => { + // Reconnect replays flow from stream open, ahead of onConnected, so each + // generation death is the only safe moment to drop generation-owned state. + sessions.handleDisconnected() + try { + sessionHistory.handleDisconnected() + } catch (error) { + console.error('[web-runtime] history disconnect failed:', error) } }, }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 18162647b4..c84a9d9df9 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -78,10 +78,24 @@ function bufferedRequestKey(envelope: RpcRequest): string | undefined case 'approval/requested': return `a:${frame.approvalId}` case 'question/requested': return `q:${envelope.rpcId}` case 'session/queue': return 'queue' + /* v8 ignore next -- pendingBuffers contains only the three frame types above. */ default: return undefined } } +/** Match ui-question's binary plan-review routing at the wire boundary. */ +function questionInteractionStatus( + questions: Extract['questions'], +): PendingInteractionStatus { + if (questions.length !== 1) return 'question' + const question = questions[0] as typeof questions[number] + const intent = question.intent + if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question' + if (question.multiSelect === true) return 'question' + const options = question.options ?? [] + if (options.length > 2) return 'question' + return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question' +} /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { @@ -620,11 +634,10 @@ export class SessionManager { // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) this.notifier.markDirty() - // New mux-generation baseline: buffered session/queue frames belong to - // the previous generation and the host is about to resend the live - // snapshot — drop them, or every reconnect appends a duplicate batch - // (and enough reconnects push real approval/question frames past the - // cap). Same re-baseline signal Session uses for its own mirror. + // New mux-generation baseline: discard the previous queue snapshot. + // The host omits session/queue when the live queue is empty, so retaining + // it could replay stale work when the Session is instantiated later. + // This is the same re-baseline signal Session uses for its own mirror. const buffered = this.pendingBuffers.get(frame.sessionId) if (buffered !== undefined) { const kept = buffered.filter(item => item.payload.type !== 'session/queue') @@ -644,9 +657,7 @@ export class SessionManager { this.trackPending( frame.sessionId, `q:${envelope.rpcId}`, - frame.questions.length === 1 && frame.questions[0]?.intent?.kind === 'plan-review' - ? 'plan-review' - : 'question', + questionInteractionStatus(frame.questions), ) } else if (frame.type === 'question/resolved') { this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) @@ -782,14 +793,14 @@ export class SessionManager { * request with its live rpcId. */ handleDisconnected(): void { + for (const session of this.sessions.values()) session.handleDisconnected() if (this.pendingInteractions.size > 0) { this.pendingInteractions.clear() this.notifier.markDirty() } for (const [sessionId, buffer] of [...this.pendingBuffers]) { const kept = buffer.filter(item => - item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved' - && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved') + item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested') if (kept.length === buffer.length) continue if (kept.length === 0) this.pendingBuffers.delete(sessionId) else this.pendingBuffers.set(sessionId, kept) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 65e10a9026..0dfef5e028 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -393,8 +393,9 @@ export class Session implements SessionFace { } /** Reconnect rebuild (manager calls this on onConnected for instances that were opened): - * reset the window and rerun open; pending waits for the baseline replay. Invalidates any - * in-flight open first — its history request rode the dead connection and must not settle + * reset the window and rerun open. Pending waits reset at generation death, before the next + * baseline replay can arrive, so this method preserves freshly replayed waits. Invalidates + * any in-flight open first — its history request rode the dead connection and must not settle * the fresh generation into 'error' (audit S4). */ async resync(): Promise { // The queue mirror is NOT cleared here: onConnected (which drives resync) @@ -410,10 +411,6 @@ export class Session implements SessionFace { this.events = [] this.views = [] this.baseSeq = 0 - // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim - // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. - this.pending.clear() - this.pendingRev++ this.subscribedLastSeq = null this.liveBuffer = [] this.notifier.markDirty() @@ -442,6 +439,16 @@ export class Session implements SessionFace { // ---- Manager-only entry points (@internal; never called by the UI) ---- + /** Discard generation-scoped waits before a next-generation replay can arrive. */ + handleDisconnected(): void { + if (this.pending.size === 0) return + // Superseded, not settled: a stale reference may already be responding, + // and the replay re-mints each still-pending request with the same rpcId. + this.pending.clear() + this.pendingRev++ + this.notifier.markDirty() + } + /** * Mux frame arrival (the dispatch switch). * @param rpcId - the frame envelope id (the respond backfill key for requested frames). diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e5691a0619..d4bbeace0b 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -76,8 +76,9 @@ describe('runtime client apply', () => { }) await Promise.resolve() expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') - // Mux sink and onConnected route without throwing (manager semantics own the behavior). + // Mux and generation-lifecycle sinks route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) + bench.sinks?.onDisconnected?.() bench.sinks?.onConnected?.() }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 118bf17d49..664d63ddc7 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -887,7 +887,7 @@ describe('pending-interaction list status', () => { expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) - it('classifies ordinary questions and plan reviews, then clears by question rpcId', () => { + it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ @@ -903,7 +903,11 @@ describe('pending-interaction list status', () => { payload: { type: 'question/requested', sessionId: S1, - questions: [{ id: 'plan', question: 'Approve?', detail: '# Plan', intent: { kind: 'plan-review', approve: 'Approve' } }], + questions: [{ + id: 'plan', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Refuse' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }], }, }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') @@ -911,6 +915,28 @@ describe('pending-interaction list status', () => { expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) + it.each([ + ['missing detail', {}], + ['multi-select', { detail: '# Plan', multiSelect: true }], + ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], + ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], + ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q-plan' as never, + payload: { + type: 'question/requested', sessionId: S1, + questions: [{ + id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + ...over, + }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + }) + it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) @@ -930,19 +956,30 @@ describe('pending-interaction list status', () => { expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale status at generation death before replay re-adds live interactions', () => { - const manager = new SessionManager(new FakeApiClient()) + it('drops stale waits at generation death and preserves replay arriving before onConnected', async () => { + const api = new FakeApiClient() + api.onHistory = () => Promise.resolve(ok({ + events: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' }, + })) + const manager = new SessionManager(api) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + const session = manager.get(S1) + await session.open() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') + expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }]) // Generation death clears (resolved-while-disconnected questions send no frame)… manager.handleDisconnected() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + expect(session.getSnapshot().pending).toEqual([]) // …and a replayed frame arriving before onConnected (stream open precedes - // the readiness handshake) survives the later handleConnected untouched. + // the readiness handshake) survives the later resync untouched. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleConnected() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') + expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }]) }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index e1c9bbeae7..ba2551f6f9 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -1098,16 +1098,19 @@ describe('remaining branches', () => { }) describe('resync', () => { - it('rebuilds the window and clears pending; cold instances no-op', async () => { + it('rebuilds the window without clearing a fresh-generation wait; cold instances no-op', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) + session.handleDisconnected() + expect(session.getSnapshot().pending).toEqual([]) + session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) await session.resync() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') - expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames + expect(snapshot.pending).toMatchObject([{ key: 'a:ra', kind: 'approval' }]) expect(snapshot.nodes).toHaveLength(4) const cold = makeSession()