From c6b552e81717e818d27386534858490b18e745b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:26 +0800 Subject: [PATCH] refactor(web): drop the permission RPC pair and turn-anchoring machinery The session.permissions/setPermission unary pair, the PermissionOption wire DTO, the client Session wrappers, and the fixture/fake mirrors all leave the wire: the read side moves to the 'permissions' session projection and the write side moves to the /permission command in follow-up commits, so the web protocol gains no permission methods at all. The pendingSwitches + prompt-submit flush + hasOpenTurn move also goes. Knob events no longer need turn enclosure: the persistence scanner keeps standalone events after the last turn/end as part of the preserved prefix (remove-synthetic-log-only-turns), none of the three knob invariants demand an open turn, and the setters append bare events. An idle switch commits immediately; hasOpenTurn stays a user-approval private fold (its audit pair is the one contract that still requires enclosure). The old PermissionSelect chip and its mount-time fetch die with the RPCs (the resident composer broke the mount-once assumption); the projection-fed replacement lands with the Access seat swap. --- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 26 --- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 8 - .../client/connection/tests/fixture.spec.ts | 19 --- packages/client/runtime/src/client/index.ts | 10 +- .../runtime/src/client/sessions/session.ts | 25 --- packages/client/runtime/tests/fake-api.ts | 8 - packages/client/runtime/tests/manager.spec.ts | 6 +- packages/client/runtime/tests/session.spec.ts | 24 --- .../skeleton/PermissionSelect.module.css | 49 ------ .../src/client/skeleton/PermissionSelect.tsx | 88 ---------- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../tests/coverage-tails.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 13 -- .../client/ui-trajectory/tests/views.spec.tsx | 2 - .../client/ui-workspace/tests/tree.spec.ts | 2 +- .../tests/workspace-browser.spec.tsx | 2 +- packages/core/session/src/index.ts | 19 --- packages/core/session/tests/session.spec.ts | 12 -- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 - .../host/apiproxy/src/api/sessions.schema.ts | 30 +--- packages/host/apiproxy/src/api/sessions.ts | 34 ---- packages/host/apiproxy/src/fetch/client.ts | 8 - packages/host/apiproxy/src/fetch/handler.ts | 4 - .../apiproxy/tests/api-proxy-approval.spec.ts | 2 +- .../tests/api-proxy-permission.spec.ts | 152 ------------------ .../apiproxy/tests/client-handler.spec.ts | 2 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 10 +- packages/ui/user-approval/src/index.ts | 17 +- 36 files changed, 37 insertions(+), 561 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css delete mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx delete mode 100644 packages/host/apiproxy/tests/api-proxy-permission.spec.ts diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 54468bce11..77f5445f6d 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8d81b07d76..56d8b90d34 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -483,12 +483,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true - /** Per-session permission preset (fixture mirror of the host permission select). */ - const permissionValues = new Map() - const PERMISSION_OPTIONS = [ - { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, - { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, - ] const fixtureQuestions: Extract['questions'] = [ { id: 'harness-profile', @@ -839,24 +833,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { accepted: true as const }) }, - permissions: (request) => { - const { sessionId: id } = request.payload - if (summaryOf(id) === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' }) - }, - setPermission: (request) => { - const { sessionId: id, value } = request.payload - if (summaryOf(id) === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (!PERMISSION_OPTIONS.some(option => option.value === value)) { - return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) - } - permissionValues.set(id, value) - return ok(request, { currentValue: value }) - }, }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), @@ -1134,8 +1110,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) - case 'session.permissions': return this.api.sessions.permissions(request) - case 'session.setPermission': return this.api.sessions.setPermission(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index af16fb540b..1ce30dbdc4 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -12,7 +12,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index f1e90bd77f..1dac997ae2 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -63,12 +63,6 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onPermissions: (payload: unknown) => - Promise> = - () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) - - onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = - payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = @@ -92,8 +86,6 @@ export class FakeApiClient implements IApiClient { this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), - setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 7ffd1f5095..7ba49dd5f5 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -345,23 +345,6 @@ describe('createFixtureApi', () => { expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) }) - it('permissions/setPermission mirror the host select: read, switch, validation', async () => { - const api = createFixtureApi() - const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) - expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } }) - const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' })) - expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) - const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) - expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) - // Validation: ghost session and unknown value. - const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') })) - expect(ghostRead.result.ok).toBe(false) - const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' })) - expect(ghostSwitch.result.ok).toBe(false) - const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' })) - expect(unknown.result.ok).toBe(false) - }) - it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -739,8 +722,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) const workspace = await client.workspace.create({ name: 'via-client' }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 63ffae4175..a7e9104b6c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -40,15 +40,7 @@ export type { PendingInteraction, PendingKind, PendingPayloads } from './session export type { ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** The permission select material as the object layer serves it to UI plugins. */ -export interface PermissionSelect { - /** Switchable presets plus (when derived) the current-only `custom`. */ - options: { value: string; name: string; description?: string }[] - /** The effective current value (`custom` when knobs match no preset). */ - currentValue: string -} +export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f354dd5305..00398d53af 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -248,31 +248,6 @@ export class Session implements ObservableSnapshot { return result } - /** - * Read the permission select (options + effective current value). - * @returns the select material, or the error branch on failure. - */ - async permissions(): Promise> { - try { - return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result - } catch (error) { - return transportError(error) - } - } - - /** - * Switch the permission preset. - * @param value - a preset value advertised by {@link Session.permissions} (never `custom`). - * @returns the confirmed current value, or the error branch on failure. - */ - async setPermission(value: string): Promise> { - try { - return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result - } catch (error) { - return transportError(error) - } - } - /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 930a9f89da..6d37f6705f 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -81,12 +81,6 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onPermissions: (payload: unknown) => - Promise> = - () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) - - onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = - payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -111,8 +105,6 @@ export class FakeApiClient implements IApiClient { this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), - setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 3aa0834f99..f2d136bef8 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -358,7 +358,7 @@ describe('connected generation', () => { describe('waiting-approval list bit', () => { it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) @@ -371,7 +371,7 @@ describe('waiting-approval list bit', () => { it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) @@ -386,7 +386,7 @@ describe('waiting-approval list bit', () => { it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => { const manager = new SessionManager(new FakeApiClient()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) manager.handleConnected() // resolved-while-disconnected questions send no frame diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 76c7b51e99..ca9193eda1 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -816,27 +816,3 @@ describe('reference stability (the memo contract)', () => { expect(resolved.pending).toBe(after.pending) }) }) - -describe('permissions / setPermission', () => { - it('passes the select read and switch through with the session id', async () => { - const { api, session } = makeSession() - api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' })) - const read = await session.permissions() - expect(read.ok).toBe(true) - if (read.ok) expect(read.value.currentValue).toBe('workspace-write') - expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }]) - - const switched = await session.setPermission('danger-full-access') - expect(switched.ok).toBe(true) - if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access') - expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }]) - }) - - it('folds transport failures into the error branch', async () => { - const { api, session } = makeSession() - api.onPermissions = () => Promise.reject(new Error('down')) - api.onSetPermission = () => Promise.reject(new Error('down')) - expect((await session.permissions()).ok).toBe(false) - expect((await session.setPermission('x')).ok).toBe(false) - }) -}) diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css deleted file mode 100644 index dd5986992c..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ /dev/null @@ -1,49 +0,0 @@ -/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a - quiet text chip with a chevron; hover paints the standard interactive pill. - The native select is stretched invisibly over the chip so the platform - dropdown does the menu work — keyboard/AT semantics come free. */ - -.root { - position: relative; - display: inline-flex; - align-items: center; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 8px; - border-radius: 8px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 20px; - pointer-events: none; /* the overlaid select owns the interaction */ -} - -.root:hover .chip { - background: var(--dsw-alias-interactive-bg-hover); -} - -.chevron { - color: var(--dsw-alias-label-caption); -} - -/* Invisible native select stretched over the chip: real menu, zero drawing. */ -.select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - opacity: 0; - border: none; - cursor: pointer; -} - -.select:disabled { - cursor: default; -} - -.root:has(.select:disabled) .chip { - opacity: 0.5; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx deleted file mode 100644 index a32e2af037..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// PermissionSelect: the composer bottom-row permission chip (draft -// start.jpeg's `Read-only ∨` control). Options and the current value load on -// mount from the injected permissions() callback; empty options -// (permission-less host composition) render nothing. The visible chip is -// presentation only — an invisible native select stretched over it owns the -// menu and interaction. A switch disables the control until the host -// confirms, then adopts the confirmed value (`custom` is shown as the current -// value but never offered as a target — the host already omits it from -// switchable options; a stale-select failure restores the previous value). - -import { useEffect, useRef, useState } from 'react' -import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/client' -import css from './PermissionSelect.module.css' - -/** - * Display transform: kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`). Presentation-only — the wire - * vocabulary and the host's advertised names are untouched; a host-configured - * name that is not kebab-case (contains spaces or uppercase) passes through. - */ -function displayName(name: string): string { - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - -export interface PermissionSelectProps { - /** Read the select material; null hides the control. */ - permissions: () => Promise - /** Switch the preset; resolves the confirmed value, or null on failure. */ - setPermission: (value: string) => Promise -} - -export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) { - const [data, setData] = useState(null) - const [switching, setSwitching] = useState(false) - // Unmount guard: the load/switch promises outlive a session switch's remount. - const aliveRef = useRef(true) - useEffect(() => { - aliveRef.current = true - void permissions().then((loaded) => { - if (aliveRef.current) setData(loaded) - }) - return () => { - aliveRef.current = false - } - }, [permissions]) - - if (data === null) return null - - const onChange = (value: string): void => { - if (value === data.currentValue) return - setSwitching(true) - const previous = data - setData({ ...data, currentValue: value }) - void setPermission(value).then((confirmed) => { - if (!aliveRef.current) return - setSwitching(false) - if (confirmed === null) setData(previous) - else setData({ ...previous, currentValue: confirmed }) - }) - } - - const current = data.options.find(option => option.value === data.currentValue) - - return ( - - ) -} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6803e3e378..b93c225680 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -55,7 +55,7 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } }, + byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: ROOT, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index afafafa3dc..34237bfebf 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -26,8 +26,8 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 }, + [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, waitingApproval: false, blank: false, updatedAt: 1 }, + [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 2 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 39d2b8283a..1b4d1ee158 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 784c04c571..44cf87cc46 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -68,7 +68,7 @@ async function bench(nodes: ToolResultNode[]) { const session = createSnapshotStore(snapshotWith(nodes)) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 90628f64ee..7a9dfe957d 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -94,7 +94,7 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9d34608156..b6263929cf 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -64,8 +64,8 @@ function mount( const sessions = createSnapshotStore({ ids: [root, SID], byId: { - [root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }, - [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 }, + [root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }, + [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 }, }, current: SID, phase: 'ready', diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index ca125d6398..93a1f15a58 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired. +- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index fcffc975b1..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -94,17 +94,4 @@ describe('SidebarRoot shell', () => { expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) - - it('waiting-approval shows the amber warning dot and outranks the running ring', () => { - mount( - summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }), - summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }), - ) - act(() => { fireEvent.click(screen.getByText('p')) }) - const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')! - const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')! - expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy() - expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull() - expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() - }) }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6e1da8afaa..917beaccc6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -149,8 +149,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} bindDraftMirror={() => () => {}} open={vi.fn()} - permissions={() => Promise.resolve(null)} - setPermission={() => Promise.resolve(null)} />, ) } diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 4af5d5f70c..eb34f633d8 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -8,7 +8,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e8405c6bcd..7513342376 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() }) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides, }) const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b6ade3da7a..e42414503b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -51,25 +51,6 @@ export function findLastMessageTurnEnd( return latest } -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`). The turn is the durable log's commit/replay - * boundary: a bare event appended between turns is indistinguishable from a - * crash tail and silently dropped on reload, so writers of turn-enclosed - * events (approval audit pairs, permission/sandbox knob switches) gate on - * this fold and hold idle writes until the next turn opens. - * @param events - session events, or an owned suffix, to inspect. - * @returns true when the last turn boundary event is a `turn/start`. - */ -export function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2edb119668..7152dd5d41 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -3,7 +3,6 @@ import { Context } from 'cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { findLastMessageTurnEnd, - hasOpenTurn, SESSION_FORMAT_VERSION, Session, SessionEvent, @@ -108,17 +107,6 @@ describe('Session', () => { expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) }) - it('reports an open turn only between turn/start and its turn/end', () => { - const session = new Session(SessionId('open-turn')) - expect(hasOpenTurn(session.events)).toBe(false) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(hasOpenTurn(session.events)).toBe(true) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(hasOpenTurn(session.events)).toBe(true) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(hasOpenTurn(session.events)).toBe(false) - }) - it('round-trips the coarse aborted turn outcome', () => { const session = new Session(SessionId('aborted')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8fc418baa5..5f27f121c4 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -27,7 +27,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, PermissionOption, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index bf41810dff..7beabd2696 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,8 +24,6 @@ export interface RpcMethodMap { 'session.selectModel': SessionsApi['selectModel'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] - 'session.permissions': SessionsApi['permissions'] - 'session.setPermission': SessionsApi['setPermission'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.openPath': HostApi['openPath'] diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index b7448021ac..8e4cdb5c5b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, PermissionOption, SessionProjectionsBlock, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -207,31 +207,3 @@ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> -/** One permission select option (a preset table key, or the derived `custom`). */ -export const permissionOptionSchema = z.object({ - value: z.string(), - name: z.string(), - description: z.string().optional(), -}) satisfies z.ZodType> - -/** session.permissions request payload. */ -export const sessionPermissionsRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** session.permissions response value. */ -export const sessionPermissionsValueSchema = z.object({ - options: z.array(permissionOptionSchema), - currentValue: z.string(), -}) satisfies z.ZodType>> - -/** session.setPermission request payload. */ -export const sessionSetPermissionRequestSchema = z.object({ - sessionId: sessionIdSchema, - value: z.string(), -}) satisfies z.ZodType>> - -/** session.setPermission response value. */ -export const sessionSetPermissionValueSchema = z.object({ - currentValue: z.string(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index a0560308dc..1a41a17f00 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -145,21 +145,6 @@ export interface SessionSummary { cwd?: string } -/** - * One selectable permission preset (or the derived `custom` state) as the - * client renders it. Protocol-owned DTO (the ACP bridge precedent: each - * protocol owns its presentation shape); the host projects it from - * `ctx.permission` without exposing that service's types on the wire. - */ -export interface PermissionOption { - /** The machine value (`session.setPermission` vocabulary): a preset table key, or `custom`. */ - value: string - /** The display label. */ - name: string - /** One user-facing sentence on what the value means. */ - description?: string -} - /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -216,23 +201,4 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> - /** - * Reads the session's permission select: every switchable preset plus the - * effective current value (`custom` when the knobs match no preset — shown, - * never a switch target). A host composed without the permission service - * returns empty options and `custom`; clients hide the control. - */ - permissions(request: RpcRequest<{ sessionId: SessionId }>): - Promise> - - /** - * Switches the session's permission preset. Mirrors the ACP bridge's - * turn-anchoring: inside an open turn the knob events append immediately; - * idle switches are held last-write-wins and flushed into the next prompted - * turn (approval-policy and sandbox-mode events must stay turn-enclosed for - * durable replay). A current-value echo is acknowledged without recording. - * Unknown values and a permission-less composition are bad-request. - */ - setPermission(request: RpcRequest<{ sessionId: SessionId; value: string }>): - Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 25888d19d6..ceca9fee7e 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -22,10 +22,8 @@ import { sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, - sessionPermissionsValueSchema, sessionPromptValueSchema, sessionSelectModelValueSchema, - sessionSetPermissionValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -61,8 +59,6 @@ export interface IApiClient { selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> - permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise>> - setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -103,8 +99,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.selectModel', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), - permissions: (payload, signal) => this.callUnary('session.permissions', payload, signal), - setPermission: (payload, signal) => this.callUnary('session.setPermission', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index c7de7854eb..505239c084 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -20,10 +20,8 @@ import { sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, - sessionPermissionsRequestSchema, sessionPromptRequestSchema, sessionSelectModelRequestSchema, - sessionSetPermissionRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, @@ -62,8 +60,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, - 'session.permissions': { schema: sessionPermissionsRequestSchema, invoke: (api, r) => api.sessions.permissions(r) }, - 'session.setPermission': { schema: sessionSetPermissionRequestSchema, invoke: (api, r) => api.sessions.setPermission(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index e0cb0c71e5..a744224814 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, api } } diff --git a/packages/host/apiproxy/tests/api-proxy-permission.spec.ts b/packages/host/apiproxy/tests/api-proxy-permission.spec.ts deleted file mode 100644 index 472a49a52b..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-permission.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Permission select over the proxy: permissions() projects the preset table - * plus the derived current value (custom shown only when derived), - * setPermission() validates against the table and anchors idle switches to - * the next prompted turn (the ACP bridge's pendingSwitches pattern), and a - * permission-less composition serves an empty select instead of an error. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import ApprovalService from '@deepseek-ai/dsh-user-approval' -import PermissionService from '@deepseek-ai/dsh-permission' -import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { createApiProxy } from '../src/api-proxy.ts' - -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} - -async function harness(options: { permission?: boolean } = {}): Promise<{ ctx: Context; api: ApiProxy; sessionId: SessionId }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.permission !== false) { - // The permission service requires a confining executor fact + approval. - ctx.provide('bash', { - sandboxMode: 'workspace-write', - resolve() { throw new Error('permission proxy tests do not execute bash') }, - run() { throw new Error('permission proxy tests do not execute bash') }, - start() { throw new Error('permission proxy tests do not execute bash') }, - }) - await ctx.plugin(ApprovalService) - await ctx.plugin(PermissionService, {}) - } - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) - // No agent-loop in this harness: register a bare live agent directly (the - // proxy only reaches `.session`); api-proxy-view.spec.ts precedent. - const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - return { ctx, api, sessionId: session.id } -} - -function expectOk(response: { result: { ok: true; value: T } | { ok: false } }): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -describe('session.permissions', () => { - it('projects the preset table with the effective current value; custom is absent when a preset matches', async () => { - const { api, sessionId } = await harness() - const value = expectOk<{ options: { value: string }[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value.currentValue).toBe('workspace-write') - expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access']) - }) - - it('serves an empty select (custom) on a permission-less composition', async () => { - const { api, sessionId } = await harness({ permission: false }) - const value = expectOk<{ options: unknown[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value).toEqual({ options: [], currentValue: 'custom' }) - }) - - it('appends the derived custom option when the knobs match no preset', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('sandbox/mode', { mode: 'read-only' }) - const value = expectOk<{ options: { value: string }[]; currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(value.currentValue).toBe('custom') - expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access', 'custom']) - }) - - it('propagates the agentFor error for a ghost session (persistence-less harness: internal)', async () => { - // The not-found/internal split is agentFor's documented gate and already - // covered by the history specs; here only the pass-through matters. - const { api } = await harness() - const response = await api.sessions.permissions(request({ sessionId: 'session-void' as SessionId })) - expect(response.result.ok).toBe(false) - }) -}) - -describe('session.setPermission', () => { - it('holds an idle switch pending (visible in permissions()) and flushes it into the next prompted turn', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - expect(agent).toBeDefined() - const switched = expectOk<{ currentValue: string }>( - await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) - expect(switched.currentValue).toBe('danger-full-access') - // No turn open: nothing appended yet; the pending value masks the fold. - expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) - const echoed = expectOk<{ currentValue: string }>( - await api.sessions.permissions(request({ sessionId }))) - expect(echoed.currentValue).toBe('danger-full-access') - - // The waterfall flush path: prompt-submit inside the new turn writes through. - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await ctx.waterfall('agent/prompt-submit', agent as never, [], { kind: 'user' } as never, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const })) - expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') - expect(agent?.session.events.map(e => e.type)).toContain('sandbox/mode') - expect(agent?.session.events.map(e => e.type)).toContain('approval/policy') - }) - - it('writes through immediately inside an open turn', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expectOk(await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) - expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') - }) - - it('acknowledges a current-value echo without recording a switch', async () => { - const { ctx, api, sessionId } = await harness() - const agent = ctx.agents.get(sessionId) - agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const echoed = expectOk<{ currentValue: string }>( - await api.sessions.setPermission(request({ sessionId, value: 'workspace-write' }))) - expect(echoed.currentValue).toBe('workspace-write') - expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) - }) - - it('propagates the agentFor error for a ghost session', async () => { - const { api } = await harness() - const response = await api.sessions.setPermission(request({ sessionId: 'session-void' as SessionId, value: 'workspace-write' })) - expect(response.result.ok).toBe(false) - }) - - it('rejects unknown values (custom included) and a permission-less composition as bad-request', async () => { - const { api, sessionId } = await harness() - for (const value of ['custom', 'nope']) { - const response = await api.sessions.setPermission(request({ sessionId, value })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - } - const bare = await harness({ permission: false }) - const response = await bare.api.sessions.setPermission(request({ sessionId: bare.sessionId, value: 'workspace-write' })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index c963b55950..2794aa13d2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -45,8 +45,6 @@ function scriptedApi(overrides: { }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), - permissions: r => ok(r, { options: [], currentValue: 'custom' }), - setPermission: r => ok(r, { currentValue: r.payload.value }), ...overrides.sessions, }, host: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d631fbc125..6c62501c60 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -73,12 +73,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, - async permissions(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { options: [], currentValue: 'custom' } } } - }, - async setPermission(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { currentValue: request.payload.value } } } - }, }, host: { async describe(request) { @@ -184,7 +178,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/permissions/setPermission/describe passthrough', async () => { + it('covers create/prompt/cancel/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) @@ -206,8 +200,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.sessions.permissions({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.sessions.setPermission({ sessionId: 's' as never, value: 'workspace-write' })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 021eab7b31..da2eafcbc7 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -11,7 +11,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -139,6 +138,22 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } +/** + * Whether the log currently sits inside an open turn (a `turn/start` not yet + * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. + * The audit pair must be turn-enclosed: the turn is the durable log's + * commit/replay boundary, so a bare event appended between turns is + * indistinguishable from a crash tail and silently dropped on reload. + */ +function hasOpenTurn(events: readonly SessionEvent[]): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const type = (events[index] as SessionEvent).type + if (type === 'turn/start') return true + if (type === 'turn/end') return false + } + return false +} + /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read.