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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<SessionId, string>()
|
||||
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<MuxFrame, { type: 'question/requested' }>['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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,12 +63,6 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onPermissions: (payload: unknown) =>
|
||||
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
|
||||
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
|
||||
|
||||
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
|
||||
payload => Promise.resolve(ok({ currentValue: payload.value }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,31 +248,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the permission select (options + effective current value).
|
||||
* @returns the select material, or the error branch on failure.
|
||||
*/
|
||||
async permissions(): Promise<RpcResult<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> {
|
||||
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<RpcResult<{ currentValue: string }>> {
|
||||
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<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
|
||||
@@ -81,12 +81,6 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onPermissions: (payload: unknown) =>
|
||||
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
|
||||
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
|
||||
|
||||
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
|
||||
payload => Promise.resolve(ok({ currentValue: payload.value }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => 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'] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PermissionSelectData | null>
|
||||
/** Switch the preset; resolves the confirmed value, or null on failure. */
|
||||
setPermission: (value: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) {
|
||||
const [data, setData] = useState<PermissionSelectData | null>(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 (
|
||||
<label className={css.root} title={current?.description}>
|
||||
<span className={css.chip}>
|
||||
{displayName(current?.name ?? data.currentValue)}
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="权限策略"
|
||||
value={data.currentValue}
|
||||
disabled={switching}
|
||||
onChange={(e) => { onChange(e.target.value) }}
|
||||
>
|
||||
{data.options.map(option => (
|
||||
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
|
||||
{displayName(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ async function bench() {
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -26,8 +26,8 @@ async function bench() {
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
|
||||
@@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -64,8 +64,8 @@ function mount(
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> = {}): 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> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } } })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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<Wire<ResponseValue<'session.cancel'>>>
|
||||
|
||||
/** 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<Wire<PermissionOption>>
|
||||
|
||||
/** session.permissions request payload. */
|
||||
export const sessionPermissionsRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.permissions'>>>
|
||||
|
||||
/** session.permissions response value. */
|
||||
export const sessionPermissionsValueSchema = z.object({
|
||||
options: z.array(permissionOptionSchema),
|
||||
currentValue: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.permissions'>>>
|
||||
|
||||
/** session.setPermission request payload. */
|
||||
export const sessionSetPermissionRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
value: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.setPermission'>>>
|
||||
|
||||
/** session.setPermission response value. */
|
||||
export const sessionSetPermissionValueSchema = z.object({
|
||||
currentValue: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.setPermission'>>>
|
||||
|
||||
@@ -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<RpcResponse<{ accepted: true }>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<{ options: PermissionOption[]; currentValue: string }>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<{ currentValue: string }>>
|
||||
}
|
||||
|
||||
@@ -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<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.permissions'>>>
|
||||
setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.setPermission'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
@@ -103,8 +99,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'session.permissions': sessionPermissionsValueSchema,
|
||||
'session.setPermission': sessionSetPermissionValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.openPath': hostOpenPathValueSchema,
|
||||
@@ -308,8 +302,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
selectModel: (payload, signal) => 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'] = {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<P>(payload: P): RpcRequest<P> {
|
||||
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<T>(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')
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user