Merge remote-tracking branch 'origin/master' into codex/simp-prune-workflow-worker-surface
This commit is contained in:
@@ -25,6 +25,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -9,4 +9,4 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)).
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).
|
||||
|
||||
@@ -30,4 +30,4 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
|
||||
@@ -134,6 +134,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'permission',
|
||||
summary: 'The permission service (`ctx.permission`).',
|
||||
methods: [
|
||||
'current(events: readonly SessionEvent[]): string',
|
||||
'resolve(name: string): PresetSpec',
|
||||
'optionOf(name: string): PresetOption',
|
||||
'set(session: Session, name: string): void',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
summary: 'Abstract process-sandbox service.',
|
||||
@@ -151,6 +161,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'abstract list(): Promise<SessionHeader[]>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
summary: 'Live-preferred logical-corpus and exact-event read service.',
|
||||
methods: [
|
||||
'listSessions(): Promise<SessionRecord[]>',
|
||||
'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
|
||||
'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
summary: 'In-memory session store (`ctx.sessions`).',
|
||||
@@ -514,6 +533,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ApprovalOutcome',
|
||||
declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
|
||||
},
|
||||
{
|
||||
name: 'ApprovalPolicy',
|
||||
declaration: 'export type ApprovalPolicy = \'ask\' | \'never\';',
|
||||
},
|
||||
{
|
||||
name: 'ApprovalRequest',
|
||||
declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}',
|
||||
@@ -742,6 +765,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'OwnerToken',
|
||||
declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
|
||||
},
|
||||
{
|
||||
name: 'PresetOption',
|
||||
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
@@ -786,10 +817,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventReadRequest',
|
||||
declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventRecord',
|
||||
declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventSurface',
|
||||
declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventType',
|
||||
declaration: 'export type SessionEventType = keyof SessionEventMap;',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventWindow',
|
||||
declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionForkSource',
|
||||
declaration: 'export type SessionForkSource = Session | SessionId;',
|
||||
@@ -802,6 +849,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionId',
|
||||
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SessionRecord',
|
||||
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillCandidate',
|
||||
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
|
||||
|
||||
@@ -49,6 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
|
||||
- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
@@ -22,8 +22,8 @@ export * from './types.ts'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceNode } from './surface.ts'
|
||||
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { isToolPairingBalanced } from './tool-pairing.ts'
|
||||
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
|
||||
@@ -61,6 +61,131 @@ export interface SurfaceNode {
|
||||
next: number | null
|
||||
}
|
||||
|
||||
/** One replacement operation observed while folding a session surface. */
|
||||
export interface SurfaceFoldReplacement {
|
||||
/** Seq of the event that replaced the prior surface range. */
|
||||
seq: number
|
||||
/** Declared inclusive start seq of the replaced surface range. */
|
||||
start: number
|
||||
/** Declared inclusive end seq of the replaced surface range. */
|
||||
end: number
|
||||
/** Actual surface nodes removed by the operation, in surface order. */
|
||||
shadowedSeqs: number[]
|
||||
}
|
||||
|
||||
/** Complete result of replaying the surface operations in a session log. */
|
||||
export interface SurfaceFoldResult {
|
||||
/** Current surface nodes in linked-list order. */
|
||||
nodes: SurfaceNode[]
|
||||
/** Replacement operations in event order. */
|
||||
replacements: SurfaceFoldReplacement[]
|
||||
}
|
||||
|
||||
/** Mutable state shared by the incremental manager and the full-log fold. */
|
||||
interface SurfaceFoldState {
|
||||
nodes: SurfaceNode[]
|
||||
nodeBySeq: Map<number, SurfaceNode>
|
||||
replaceGeneration: number
|
||||
}
|
||||
|
||||
/** Create an empty surface fold state. */
|
||||
function createFoldState(replaceGeneration = 0): SurfaceFoldState {
|
||||
return {
|
||||
nodes: [],
|
||||
nodeBySeq: new Map(),
|
||||
replaceGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one event and return replacement metadata only when one occurred. */
|
||||
function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
if (!isSurfaceEligibleType(event.type)) return
|
||||
if (!isSurfaceEvent(event)) {
|
||||
throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
|
||||
}
|
||||
|
||||
if (event.surfaceOp === 'append') {
|
||||
const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined
|
||||
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
|
||||
if (tail) tail.next = event.seq
|
||||
state.nodes.push(node)
|
||||
state.nodeBySeq.set(event.seq, node)
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
seq: event.seq,
|
||||
start: event.surfaceOp.start,
|
||||
end: event.surfaceOp.end,
|
||||
shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp),
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one positional replacement and return the nodes it removed. */
|
||||
function replaceSurface(
|
||||
state: SurfaceFoldState,
|
||||
newSeq: number,
|
||||
op: Extract<SurfaceOp, { op: 'replace' }>,
|
||||
): number[] {
|
||||
const startNode = state.nodeBySeq.get(op.start)
|
||||
if (!startNode) {
|
||||
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
|
||||
}
|
||||
const endNode = state.nodeBySeq.get(op.end)
|
||||
if (!endNode) {
|
||||
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
|
||||
}
|
||||
const startIdx = state.nodes.indexOf(startNode)
|
||||
const endIdx = state.nodes.indexOf(endNode)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
|
||||
}
|
||||
|
||||
const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1)
|
||||
for (const node of removed) state.nodeBySeq.delete(node.seq)
|
||||
|
||||
const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined
|
||||
const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined
|
||||
const newNode: SurfaceNode = {
|
||||
seq: newSeq,
|
||||
prev: prevNode?.seq ?? null,
|
||||
next: nextNode?.seq ?? null,
|
||||
}
|
||||
if (prevNode) prevNode.next = newSeq
|
||||
if (nextNode) nextNode.prev = newSeq
|
||||
state.nodes.splice(startIdx, 0, newNode)
|
||||
state.nodeBySeq.set(newSeq, newNode)
|
||||
state.replaceGeneration += 1
|
||||
return removed.map(node => node.seq)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
*
|
||||
* The returned arrays and nodes are detached snapshots. The incremental
|
||||
* {@link SurfaceManager} uses the same transition functions, so query read
|
||||
* models cannot disagree with `deriveMessages()` about replacement ranges.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns the current surface and every positional replacement.
|
||||
* @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a
|
||||
* replacement names nodes that are absent or reversed on the current surface.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const event of events) {
|
||||
const replacement = applySurfaceEvent(state, event)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return {
|
||||
nodes: state.nodes.map(node => ({ ...node })),
|
||||
replacements,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a cached linked list of surface nodes, rebuilt lazily from
|
||||
* `surfaceOp` markers in the event log. Because the log is append-only, it
|
||||
@@ -69,16 +194,11 @@ export interface SurfaceNode {
|
||||
* whole log.
|
||||
*/
|
||||
export class SurfaceManager {
|
||||
/** Surface nodes in linked-list order (head to tail). Empty until first access. */
|
||||
private _nodes: SurfaceNode[] = []
|
||||
/** Map from event seq → node. */
|
||||
private _nodeBySeq = new Map<number, SurfaceNode>()
|
||||
/** Incremental state shared with the complete surface fold. */
|
||||
private _state = createFoldState()
|
||||
/** The last processed seq. -1 forces a full rebuild on first access. */
|
||||
private _lastProcessedSeq = -1
|
||||
|
||||
/** Rewrite generation — see {@link replaceGeneration}. */
|
||||
private _replaceGeneration = 0
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
|
||||
/**
|
||||
@@ -88,11 +208,9 @@ export class SurfaceManager {
|
||||
*/
|
||||
invalidate(): void {
|
||||
this._lastProcessedSeq = -1
|
||||
this._nodes = []
|
||||
this._nodeBySeq.clear()
|
||||
// A wholesale rebuild is a rewrite: bump the generation so incremental
|
||||
// consumers (the session's derived-message cache) discard their view.
|
||||
this._replaceGeneration += 1
|
||||
this._state = createFoldState(this._state.replaceGeneration + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +224,13 @@ export class SurfaceManager {
|
||||
*/
|
||||
get replaceGeneration(): number {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
return this._replaceGeneration
|
||||
return this._state.replaceGeneration
|
||||
}
|
||||
|
||||
/** The surface nodes in linked-list order (head to tail). */
|
||||
get nodes(): readonly SurfaceNode[] {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
return this._nodes
|
||||
return this._state.nodes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,61 +242,8 @@ export class SurfaceManager {
|
||||
// Index is bounded by i < this.log.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = this.log[i]!
|
||||
// isSurfaceEvent checks event.type first (is it a surface-eligible type?)
|
||||
// then checks that surfaceOp is present. Only after both pass do we treat
|
||||
// it as a SurfaceEvent with mandatory surfaceOp.
|
||||
if (!isSurfaceEvent(event)) continue
|
||||
|
||||
if (event.surfaceOp === 'append') {
|
||||
const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined
|
||||
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
|
||||
if (tail) tail.next = event.seq
|
||||
this._nodes.push(node)
|
||||
this._nodeBySeq.set(event.seq, node)
|
||||
} else {
|
||||
this._replace(event.seq, event.surfaceOp)
|
||||
}
|
||||
applySurfaceEvent(this._state, event)
|
||||
}
|
||||
this._lastProcessedSeq = this.log.length - 1
|
||||
}
|
||||
|
||||
/** Apply a replace operation to the in-progress surface. */
|
||||
private _replace(
|
||||
newSeq: number,
|
||||
op: Extract<SurfaceOp, { op: 'replace' }>,
|
||||
): void {
|
||||
const startNode = this._nodeBySeq.get(op.start)
|
||||
if (!startNode) {
|
||||
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
|
||||
}
|
||||
const endNode = this._nodeBySeq.get(op.end)
|
||||
if (!endNode) {
|
||||
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
|
||||
}
|
||||
const startIdx = this._nodes.indexOf(startNode)
|
||||
const endIdx = this._nodes.indexOf(endNode)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
|
||||
}
|
||||
|
||||
// Remove shadowed nodes from `[startIdx, endIdx]` inclusive.
|
||||
const count = endIdx - startIdx + 1
|
||||
const removed = this._nodes.splice(startIdx, count)
|
||||
for (const r of removed) this._nodeBySeq.delete(r.seq)
|
||||
|
||||
// Insert the new node where the removed range was.
|
||||
const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined
|
||||
const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined
|
||||
|
||||
const newNode: SurfaceNode = {
|
||||
seq: newSeq,
|
||||
prev: prevNode?.seq ?? null,
|
||||
next: nextNode?.seq ?? null,
|
||||
}
|
||||
if (prevNode) prevNode.next = newSeq
|
||||
if (nextNode) nextNode.prev = newSeq
|
||||
this._nodes.splice(startIdx, 0, newNode)
|
||||
this._nodeBySeq.set(newSeq, newNode)
|
||||
this._replaceGeneration += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
@@ -14,6 +14,59 @@ function surfaceSession(): Session {
|
||||
}
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
||||
s.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
|
||||
|
||||
const folded = foldSurface(s.events)
|
||||
expect(folded.nodes).toEqual(s.surface.nodes)
|
||||
expect(folded.replacements).toEqual([
|
||||
{ seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
|
||||
{ seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
|
||||
])
|
||||
folded.nodes[0]!.next = 99
|
||||
folded.replacements[0]!.shadowedSeqs.push(99)
|
||||
expect(s.surface.nodes).toEqual([{ seq: 3, prev: null, next: null }])
|
||||
expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0])
|
||||
})
|
||||
|
||||
it('does not retain fold-only replacement history in incremental state', () => {
|
||||
const s = new Session(SessionId('incremental-state'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } })
|
||||
|
||||
expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }])
|
||||
const manager = s.surface as unknown as { _state: object }
|
||||
expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
|
||||
expect(foldSurface(s.events).replacements).toEqual([
|
||||
{ seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
|
||||
])
|
||||
})
|
||||
|
||||
it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
|
||||
const s = new Session(SessionId('shared-fold-invalid'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] })
|
||||
|
||||
expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 42 not found/)
|
||||
})
|
||||
|
||||
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
|
||||
const malformed: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
|
||||
}
|
||||
|
||||
expect(() => foldSurface([malformed]))
|
||||
.toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('rebuilds a linked list from surfaceOp: append markers', () => {
|
||||
const s = surfaceSession()
|
||||
const nodes = s.surface.nodes
|
||||
|
||||
@@ -9,4 +9,4 @@ The confinement half of the [capability-seam split](../../docs/rfc/implemented/a
|
||||
|
||||
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
|
||||
|
||||
@@ -15,4 +15,4 @@ Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
```
|
||||
|
||||
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable composition.
|
||||
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example](../../../examples/acp-agent/) for the runnable default composition.
|
||||
|
||||
9
packages/session-query/README.md
Normal file
9
packages/session-query/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# session-query/ — session retrieval capability family
|
||||
|
||||
Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` |
|
||||
|
||||
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package.
|
||||
23
packages/session-query/session-query/README.md
Normal file
23
packages/session-query/session-query/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
|
||||
|
||||
This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
44
packages/session-query/session-query/package.json
Normal file
44
packages/session-query/session-query/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-query",
|
||||
"description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
33
packages/session-query/session-query/src/config.ts
Normal file
33
packages/session-query/session-query/src/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Public configuration and typed failures for session-query. */
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Default maximum `before`/`after` raw-event window. */
|
||||
export const SESSION_QUERY_READ_WINDOW_MAX = 50
|
||||
|
||||
/** Configuration for exact session-query reads. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
}
|
||||
|
||||
/** Stable machine-routable failure taxonomy for exact session reads. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
/** Typed session-query failure whose `code` is one closed taxonomy member. */
|
||||
export class SessionQueryError extends HarnessError {
|
||||
declare readonly code: SessionQueryErrorCode
|
||||
|
||||
// The base stores the value; this signature narrows its open string code.
|
||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||
constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
}
|
||||
}
|
||||
136
packages/session-query/session-query/src/corpus.ts
Normal file
136
packages/session-query/session-query/src/corpus.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/** Live/persisted logical-corpus resolution for session-query. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionRecord } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/** Detached source selected for one exact read. */
|
||||
export interface LogicalSession {
|
||||
/** Cloned source header. */
|
||||
header: SessionHeader
|
||||
/** Cloned raw event log. */
|
||||
events: SessionEvent[]
|
||||
}
|
||||
|
||||
/** Resolves a live-preferred corpus against the persistence service mounted now. */
|
||||
export class SessionCorpus {
|
||||
private _persistence: SessionPersistence | undefined
|
||||
|
||||
constructor(private readonly _ctx: Context) {
|
||||
_ctx.effect(() => {
|
||||
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
this._persistence = service
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
||||
if (this._persistence === service) this._persistence = undefined
|
||||
}, 'sessionQuery.persistenceBinding')
|
||||
})
|
||||
return () => void fiber.dispose()
|
||||
}, 'sessionQuery.optionalPersistence')
|
||||
}
|
||||
|
||||
/**
|
||||
* List the complete logical corpus with live precedence and cloned headers.
|
||||
* @returns records in deterministic newest-first order.
|
||||
*/
|
||||
async listSessions(): Promise<SessionRecord[]> {
|
||||
const persistence = this._persistence
|
||||
const persisted = persistence === undefined ? [] : await listPersisted(persistence)
|
||||
const records = new Map<SessionId, SessionRecord>()
|
||||
for (const header of persisted) {
|
||||
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
|
||||
}
|
||||
for (const session of this._ctx.sessions.list()) {
|
||||
const durable = records.get(session.id)
|
||||
if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header)
|
||||
records.set(session.id, {
|
||||
header: structuredClone(session.header),
|
||||
live: true,
|
||||
persisted: durable !== undefined,
|
||||
})
|
||||
}
|
||||
return [...records.values()].sort(compareSessions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one logical source, preferring a detached live snapshot.
|
||||
*
|
||||
* A known live target never consults persistence, so an optional backend's
|
||||
* failure cannot make current in-memory history unreadable.
|
||||
* @param sessionId - session to resolve.
|
||||
* @returns detached live-preferred header and events.
|
||||
*/
|
||||
async load(sessionId: SessionId): Promise<LogicalSession> {
|
||||
const live = this._ctx.sessions.get(sessionId)
|
||||
if (live !== undefined) return snapshotLive(live)
|
||||
const persistence = this._persistence
|
||||
if (persistence === undefined) throw notFound(sessionId)
|
||||
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
|
||||
if (listed === undefined) throw notFound(sessionId)
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
|
||||
try {
|
||||
loaded = await persistence.load(sessionId)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`failed to load session "${sessionId}": ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
assertCompatibleHeaders(loaded.meta, listed)
|
||||
return {
|
||||
header: structuredClone(loaded.meta),
|
||||
events: loaded.events.map(event => structuredClone(event)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listPersisted(persistence: SessionPersistence): Promise<SessionHeader[]> {
|
||||
try {
|
||||
return await persistence.list()
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`session persistence listing failed: ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotLive(session: Session): LogicalSession {
|
||||
return {
|
||||
header: structuredClone(session.header),
|
||||
events: session.events.map(event => structuredClone(event)),
|
||||
}
|
||||
}
|
||||
|
||||
function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
|
||||
if (
|
||||
a.version !== b.version
|
||||
|| a.id !== b.id
|
||||
|| a.createdAt !== b.createdAt
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`live and persisted headers conflict for session "${a.id}"`,
|
||||
'SESSION_QUERY_SOURCE_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function compareSessions(a: SessionRecord, b: SessionRecord): number {
|
||||
return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id)
|
||||
}
|
||||
|
||||
function notFound(sessionId: SessionId): SessionQueryError {
|
||||
return new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'unknown error'
|
||||
}
|
||||
136
packages/session-query/session-query/src/index.ts
Normal file
136
packages/session-query/session-query/src/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Exact session-history reads over live and optionally persisted logs.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventWindow,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
SessionQueryError,
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionQuery: SessionQueryService
|
||||
}
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus and exact-event read service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
|
||||
})
|
||||
|
||||
private readonly _readWindowMax: number
|
||||
private readonly _corpus: SessionCorpus
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'sessionQuery')
|
||||
this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
|
||||
if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) {
|
||||
throw new SessionQueryError(
|
||||
'session-query: readWindowMax must be a non-negative integer',
|
||||
'SESSION_QUERY_INVALID_CONFIG',
|
||||
)
|
||||
}
|
||||
this._corpus = new SessionCorpus(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* List the complete logical corpus using live-preferred records.
|
||||
* @returns deterministic newest-first cloned session records.
|
||||
*/
|
||||
listSessions(): Promise<SessionRecord[]> {
|
||||
return this._corpus.listSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* List lightweight raw-log event records for one logical session.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
* @returns event records in ascending seq order.
|
||||
*/
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one full event plus a bounded raw-log context window.
|
||||
* @param request - target session/seq and context sizes.
|
||||
* @returns cloned target and neighboring events.
|
||||
*/
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
|
||||
const before = this._readWindow('before', request.before)
|
||||
const after = this._readWindow('after', request.after)
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
const target = loaded.events[request.seq]
|
||||
if (target === undefined || target.seq !== request.seq) {
|
||||
throw new SessionQueryError(
|
||||
`session "${request.sessionId}" has no event at seq ${request.seq}`,
|
||||
'SESSION_QUERY_EVENT_NOT_FOUND',
|
||||
)
|
||||
}
|
||||
const startSeq = Math.max(0, request.seq - before)
|
||||
const endSeq = Math.min(loaded.events.length - 1, request.seq + after)
|
||||
return {
|
||||
session: loaded.header,
|
||||
target,
|
||||
events: loaded.events.slice(startSeq, endSeq + 1),
|
||||
startSeq,
|
||||
endSeq,
|
||||
}
|
||||
}
|
||||
|
||||
private _readWindow(name: 'before' | 'after', value: number | undefined): number {
|
||||
if (value === undefined) return 0
|
||||
if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) {
|
||||
throw new SessionQueryError(
|
||||
`${name} must be an integer between 0 and ${this._readWindowMax}`,
|
||||
'SESSION_QUERY_INVALID_WINDOW',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
|
||||
let folded: ReturnType<typeof foldSurface>
|
||||
try {
|
||||
folded = foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const current = new Set(folded.nodes.map(node => node.seq))
|
||||
const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
|
||||
return events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
|
||||
}))
|
||||
}
|
||||
|
||||
export default SessionQueryService
|
||||
60
packages/session-query/session-query/src/types.ts
Normal file
60
packages/session-query/session-query/src/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Public records for exact reads over the live-preferred logical session corpus.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query/types
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Whether an event is current model context, replaced context, or raw-log-only. */
|
||||
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
|
||||
|
||||
/** Lightweight identity and source availability for one logical session. */
|
||||
export interface SessionRecord {
|
||||
/** Cloned session header selected from the live-preferred corpus. */
|
||||
header: SessionHeader
|
||||
/** Whether the id currently exists in `ctx.sessions`. */
|
||||
live: boolean
|
||||
/** Whether the active persistence backend currently materializes the id. */
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
/** Lightweight metadata for one event within a logical session. */
|
||||
export interface SessionEventRecord {
|
||||
/** Session that owns the event. */
|
||||
sessionId: SessionId
|
||||
/** Monotonic event seq within the session. */
|
||||
seq: number
|
||||
/** Discriminant of the session event. */
|
||||
type: SessionEventType
|
||||
/** Event timestamp in Unix epoch milliseconds. */
|
||||
time: number
|
||||
/** Event placement in the folded session surface. */
|
||||
surface: SessionEventSurface
|
||||
}
|
||||
|
||||
/** Request for one event plus raw neighboring log context. */
|
||||
export interface SessionEventReadRequest {
|
||||
/** Session that owns the target event. */
|
||||
sessionId: SessionId
|
||||
/** Target event seq. */
|
||||
seq: number
|
||||
/** Number of preceding raw events to include. */
|
||||
before?: number
|
||||
/** Number of following raw events to include. */
|
||||
after?: number
|
||||
}
|
||||
|
||||
/** Full target event and a bounded raw-log window. */
|
||||
export interface SessionEventWindow {
|
||||
/** Cloned header for the live-preferred source read. */
|
||||
session: SessionHeader
|
||||
/** Full cloned target event. */
|
||||
target: SessionEvent
|
||||
/** Full cloned events from `startSeq` through `endSeq`. */
|
||||
events: SessionEvent[]
|
||||
/** First seq included in `events`. */
|
||||
startSeq: number
|
||||
/** Last seq included in `events`. */
|
||||
endSeq: number
|
||||
}
|
||||
270
packages/session-query/session-query/tests/session-query.spec.ts
Normal file
270
packages/session-query/session-query/tests/session-query.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
}
|
||||
|
||||
function eventLog(text = 'hello'): SessionEvent[] {
|
||||
return [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 10,
|
||||
data: { content: [{ type: 'text', text }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
}]
|
||||
}
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listFailure: unknown
|
||||
static loadFailure: unknown
|
||||
static afterList: (() => void) | undefined
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
|
||||
this.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
entry.events.push(...structuredClone(events))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure)
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure)
|
||||
const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
||||
TestPersistence.afterList?.()
|
||||
return Promise.resolve(headers)
|
||||
}
|
||||
}
|
||||
|
||||
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
return new Promise<T>((_resolve, reject) => {
|
||||
// Exercise containment for an implementation that violates the Error rejection convention.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(reason)
|
||||
})
|
||||
}
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('lists live sessions deterministically and returns detached headers', async () => {
|
||||
const ctx = await liveContext()
|
||||
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|
||||
ctx.sessions.create(SessionId('z'), { meta: { createdAt: 2 } })
|
||||
ctx.sessions.create(SessionId('a'), { meta: { createdAt: 2 } })
|
||||
|
||||
const records = await ctx.sessionQuery.listSessions()
|
||||
expect(records.map(record => record.header.id)).toEqual([SessionId('a'), SessionId('z'), older.id])
|
||||
expect(records.every(record => record.live && !record.persisted)).toBe(true)
|
||||
Object.assign(records[2]!.header, { createdAt: 99 })
|
||||
expect(older.header.createdAt).toBe(1)
|
||||
})
|
||||
|
||||
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface'))
|
||||
const first = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
|
||||
)
|
||||
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
|
||||
.toEqual(['shadowed', 'log-only', 'current'])
|
||||
})
|
||||
|
||||
it('returns a bounded detached raw-event window and validates the request', async () => {
|
||||
const ctx = await liveContext({ readWindowMax: 1 })
|
||||
const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } })
|
||||
for (const text of ['one', 'two', 'three']) {
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 })
|
||||
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1])
|
||||
expect(result.session).toEqual(session.header)
|
||||
Object.assign(result.session, { createdAt: -1 })
|
||||
if (result.events[0]?.type !== 'user/message') throw new Error('expected user message')
|
||||
result.events[0].data.content = []
|
||||
expect(session.header.createdAt).not.toBe(-1)
|
||||
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
|
||||
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
for (const request of [
|
||||
{ sessionId: session.id, seq: 0, before: -1 },
|
||||
{ sessionId: session.id, seq: 0, before: 2 },
|
||||
{ sessionId: session.id, seq: 0, after: 0.5 },
|
||||
]) {
|
||||
await expect(ctx.sessionQuery.readEvent(request)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
|
||||
}
|
||||
})
|
||||
|
||||
it('merges authoritative persistence with live precedence and detects conflicts', async () => {
|
||||
const shared = header('shared', 3, { cwd: '/same' })
|
||||
const durable = header('durable', 2)
|
||||
TestPersistence.reset([
|
||||
{ meta: shared, events: eventLog('persisted') },
|
||||
{ meta: durable, events: eventLog('durable') },
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
|
||||
expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted]))
|
||||
.toEqual([[shared.id, true, true], [durable.id, false, true]])
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
|
||||
expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0])
|
||||
.toMatchObject({ text: 'live' })
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ session: durable })
|
||||
|
||||
const sharedEntry = TestPersistence.entries.get(shared.id)!
|
||||
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/conflict' }
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
await persistence.dispose()
|
||||
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([
|
||||
{ header: shared, live: true, persisted: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps known live reads independent from persistence health', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(SessionId('live'))
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.loadFailure = new Error('load unavailable')
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } })
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => {
|
||||
const durable = header('durable')
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('absent')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
await ctx.plugin(TestPersistence)
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('absent')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
|
||||
TestPersistence.loadFailure = 'raw failure'
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TestPersistence.loadFailure = undefined
|
||||
const durableEntry = TestPersistence.entries.get(durable.id)!
|
||||
durableEntry.meta = { ...durableEntry.meta, cwd: '/changed-after-list' }
|
||||
TestPersistence.afterList = () => {
|
||||
const listedEntry = TestPersistence.entries.get(durable.id)!
|
||||
listedEntry.meta = { ...listedEntry.meta, cwd: '/changed-during-read' }
|
||||
}
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('bad-surface'))
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [] },
|
||||
{ surfaceOp: { op: 'replace', start: 9, end: 9 } },
|
||||
)
|
||||
await expect(ctx.sessionQuery.listEvents(session.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
const persisted = header('bad-persisted-surface')
|
||||
TestPersistence.reset([{
|
||||
meta: persisted,
|
||||
events: [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
|
||||
}],
|
||||
}])
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
await expect(ctx.sessionQuery.listEvents(persisted.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
await persistence.dispose()
|
||||
|
||||
const direct = new Context()
|
||||
await direct.plugin(SessionStore)
|
||||
expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SessionStore)
|
||||
expect(() => new SessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
|
||||
})
|
||||
|
||||
it('leaves the optional persistence dependency optional', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionQueryService)
|
||||
expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService)
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
})
|
||||
30
packages/session-query/session-query/tsconfig.json
Normal file
30
packages/session-query/session-query/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
|
||||
config:
|
||||
providerName: acp
|
||||
command: node
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
||||
permission: reject
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
@@ -48,7 +48,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
|
||||
cwd: workdir,
|
||||
permission: 'reject',
|
||||
// The child harness needs the key to reach the model; forward it
|
||||
@@ -57,6 +57,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -83,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
|
||||
cwd: workdir,
|
||||
// The child needs to act (run bash), so approve its permission prompts.
|
||||
permission: 'allow',
|
||||
@@ -91,6 +92,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath],
|
||||
['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath],
|
||||
{ cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
|
||||
@@ -29,11 +29,11 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`):
|
||||
`dsh-acp-agent [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`):
|
||||
|
||||
- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call;
|
||||
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
* STDERR only (the app plugin loads no stdout logger, and the shared guards
|
||||
* write to stderr); a stray stdout write corrupts the protocol frames.
|
||||
*
|
||||
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
|
||||
* Usage: `dsh-acp-agent [--config path-to-cordis.yml]` (default
|
||||
* `./cordis.yml`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp-agent/bin
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh-acp-agent'
|
||||
@@ -37,7 +39,12 @@ const NAME = 'dsh-acp-agent'
|
||||
installFailLoud(NAME)
|
||||
const snapshotMode = process.env['DSH_SNAPSHOT']
|
||||
if (snapshotMode !== 'replay') loadEnv(NAME)
|
||||
const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: { config: { type: 'string', short: 'c' } },
|
||||
strict: true,
|
||||
})
|
||||
const ctx = await boot(NAME, resolveConfigPath(values.config ?? './cordis.yml', snapshotMode))
|
||||
if (snapshotMode !== undefined) {
|
||||
process.stdin.on('end', () => {
|
||||
void ctx.fiber.dispose().then(() => { process.exit(0) })
|
||||
|
||||
@@ -117,7 +117,7 @@ afterEach(async () => {
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
|
||||
consumer = await makeConsumer()
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: {
|
||||
@@ -183,7 +183,7 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
|
||||
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
|
||||
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -82,7 +82,7 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
await writeFile(configPath, CORDIS_YML)
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, binScript, configPath],
|
||||
['--import', tsxLoader, binScript, '--config', configPath],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
|
||||
@@ -32,7 +32,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
|
||||
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
|
||||
## Multi-session
|
||||
|
||||
@@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in
|
||||
|
||||
## Session config options
|
||||
|
||||
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
|
||||
When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
|
||||
|
||||
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session sandbox/approval config options. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
@@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
|
||||
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
|
||||
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
|
||||
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
|
||||
@@ -28,36 +28,38 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -74,10 +74,8 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
@@ -328,7 +326,7 @@ interface SessionRecord {
|
||||
* overlay it truthfully, and a restart before the next turn reverts it —
|
||||
* which `session/load` then reports honestly from the log's fold.
|
||||
*/
|
||||
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,46 +548,37 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* The session config options this composition can honor, with current
|
||||
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
|
||||
* `effectiveApprovalPolicy` — the log is the per-session store, so a
|
||||
* `session/load` reports a resumed session's overrides with no catch-up
|
||||
* machinery), overlaid with the record's not-yet-anchored pending switches
|
||||
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: the sandbox option exists only when the mounted
|
||||
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
|
||||
* option only when the approval seam is composed — both read
|
||||
* The session config options this composition can honor: ONE `Mode`
|
||||
* select over the composed preset table (`ctx.permission` — the product
|
||||
* layer bundling the sandbox-mode and approval-policy knobs), its current
|
||||
* value folded from the AGENT'S OWN session log (the log is the
|
||||
* per-session store, so a `session/load` reports a resumed session's
|
||||
* preset with no catch-up machinery), overlaid with the record's
|
||||
* not-yet-anchored pending switch (see
|
||||
* {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: no preset service composed, no options — read
|
||||
* opportunistically so this bridge keeps working in compositions without
|
||||
* them.
|
||||
* it.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
const options: SessionConfigOption[] = []
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode !== undefined) {
|
||||
options.push({
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
|
||||
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
|
||||
})
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval !== undefined) {
|
||||
options.push({
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
// `?? 'ask'` also shields against a provided stand-in whose config
|
||||
// never went through the plugin schema (tests do this).
|
||||
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
|
||||
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
|
||||
})
|
||||
}
|
||||
return options
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) return []
|
||||
const currentValue = pending.preset ?? presets.current(agent.session.events)
|
||||
return [{
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
...presets.names.map((name: string) => presets.optionOf(name)),
|
||||
// The derived not-a-preset state: visible exactly while it IS the
|
||||
// current value (a knob state outside the table), switchable FROM,
|
||||
// never a target — set() below rejects it like any unknown name.
|
||||
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -619,15 +608,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
const events = rec.agent.session.events
|
||||
if (pending.sandboxMode !== undefined
|
||||
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
|
||||
setSandboxMode(rec.agent.session, pending.sandboxMode)
|
||||
}
|
||||
if (pending.approvalPolicy !== undefined
|
||||
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
|
||||
}
|
||||
if (pending.preset === undefined) return
|
||||
const presets = ctx.get('permission')
|
||||
/* v8 ignore next -- a pending preset exists only if the service answered the
|
||||
switch; it cannot unmount between that and the next turn in any composition. */
|
||||
if (presets === undefined) return
|
||||
presets.set(rec.agent.session, pending.preset)
|
||||
}
|
||||
|
||||
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
|
||||
@@ -869,7 +855,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
// Both advertised options are selects, so the boolean-shaped variant of
|
||||
// The advertised option is a select, so the boolean-shaped variant of
|
||||
// the request is a protocol misuse regardless of configId.
|
||||
if (typeof params.value !== 'string') {
|
||||
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
|
||||
@@ -886,32 +872,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// advertised; an id this composition never advertised (or an unknown
|
||||
// one) rejects.
|
||||
switch (params.configId) {
|
||||
case 'sandbox-mode': {
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
|
||||
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
|
||||
case 'permission': {
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as SandboxMode
|
||||
// A no-op switch (the value the session already shows — pending,
|
||||
// else fold, else default) is acknowledged without recording
|
||||
// anything: clients that re-push current selections on session
|
||||
// start must not mint override events out of thin air.
|
||||
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
|
||||
else rec.pendingSwitches.sandboxMode = value
|
||||
break
|
||||
}
|
||||
case 'approval-policy': {
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
|
||||
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
|
||||
// else derived) is acknowledged FIRST and records nothing:
|
||||
// clients re-push current selections on session start, and the
|
||||
// derived 'custom' current is only ever valid as such an echo.
|
||||
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
|
||||
if (params.value === current) break
|
||||
if (!presets.names.includes(params.value)) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as ApprovalPolicy
|
||||
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
|
||||
else rec.pendingSwitches.approvalPolicy = value
|
||||
if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value)
|
||||
else rec.pendingSwitches.preset = params.value
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Session config options over the bridge: the two per-session knobs
|
||||
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
|
||||
* their current values folded from each session's own log, switching via
|
||||
* `session/set_config_option` (one log-only event per switch — the log is the
|
||||
* store), and a resumed session reporting its overrides back on
|
||||
* `session/load` with no catch-up machinery.
|
||||
* Session config options over the bridge: ONE user-facing `Permissions`
|
||||
* select (`ctx.permission`'s preset table — each choice bundles a sandbox
|
||||
* mode and an approval policy), its current value folded from each session's
|
||||
* own log, switching via `session/set_config_option` (the preset event plus
|
||||
* its knob write-throughs — the log is the store), and a resumed session
|
||||
* reporting its preset back on `session/load` with no catch-up machinery.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -14,50 +14,37 @@ import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import PermissionService from '@deepseek-ai/dsh-permission'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The REAL local executor reporting a confining default — `sandboxMode` is
|
||||
* the documented capability override point (`dsh-bash-sandbox` overrides it
|
||||
* the same way), so the bridge sees exactly what a sandboxing composition
|
||||
* advertises without this suite dragging in a kernel sandbox stack.
|
||||
* advertises without this suite dragging in a kernel sandbox stack. It
|
||||
* reports `workspace-write`: the shipped preset's bundle, which
|
||||
* the permission service validates the composition defaults against.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'read-only'
|
||||
return 'workspace-write'
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact option payloads the bridge advertises (pinned verbatim). */
|
||||
function sandboxOption(currentValue: SandboxMode): object {
|
||||
/** The exact option payload the bridge advertises (pinned verbatim). */
|
||||
function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function approvalOption(currentValue: ApprovalPolicy): object {
|
||||
return {
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'ask', name: 'ask' },
|
||||
{ value: 'never', name: 'never' },
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -75,144 +62,113 @@ describe('acp bridge — session config options', () => {
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
|
||||
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
/** A harness composing the full preset stack (confining executor + approval seam + permission presets). */
|
||||
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// The dev invariants police turn-enclosure: an idle switch that appended
|
||||
// outside a turn would throw right here in the suite, not in production.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
await harness.ctx.plugin(PermissionService)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions in a composition with neither knob', async () => {
|
||||
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, withBash: true })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('advertises both knobs with capability-derived currents (config default included)', async () => {
|
||||
h = await bothKnobs({ policy: 'never' })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
|
||||
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
|
||||
// (the dev invariants in this suite would throw), so the switch lives on
|
||||
// the record until a turn opens.
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
// The next turn anchors both switches inside itself, one event per knob.
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
it('an idle flip-flop anchors as ONE switch (last write wins)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
|
||||
// switch pends rather than appending outside the closed turn.
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Re-pushing the composition default (what clients that echo current
|
||||
// selections on session start do) must not mint an override event.
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
|
||||
// Re-sending a PENDING value keeps the pending switch alive (it is what
|
||||
// the session shows), rather than cancelling it.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0)
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
// Between turns (a closed turn in the log) a switch still pends — the
|
||||
// enclosure fold walks past the turn/end — and anchors with the NEXT turn.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0)
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
})
|
||||
|
||||
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
|
||||
h = await bothKnobs({ script: ['hang'] })
|
||||
h = await presetStack({ script: ['hang'] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
// Give the loop a tick to open the turn (the turns.spec hang idiom).
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
})
|
||||
|
||||
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
const sessionId = res.sessionId
|
||||
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions).toEqual([approvalOption('ask')])
|
||||
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
})
|
||||
|
||||
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
@@ -221,40 +177,72 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// sandbox-mode exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
|
||||
.rejects.toThrow(/unknown sandbox-mode value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
|
||||
// `permission` exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
|
||||
.rejects.toThrow(/unknown approval-policy value/)
|
||||
})
|
||||
|
||||
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
|
||||
h = await bothKnobs()
|
||||
h = await presetStack()
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
// B sees its own composition defaults, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
// B sees the composition default, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
// ...and A keeps its own state, untouched by B's.
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s overrides from its own log', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
// Drift a knob out from under the table (a plugin writing the knob
|
||||
// directly — the raw setters remain public mechanism), inside its own
|
||||
// turn: the dev invariants enforce turn-enclosure here too.
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The echo of the derived current is a no-op, not an unknown-value error…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
|
||||
// …while custom as a TARGET from a real preset stays rejected: switching
|
||||
// away is ordinary, and the custom entry disappears from the options.
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s preset from its own log', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
// One turn checkpoints the log (the switch events flush with it).
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await bothKnobs()
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
|
||||
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../permission"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from a
|
||||
|
||||
## Config discovery
|
||||
|
||||
Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent <path/to/cordis.yml>`, the human channel, isomorphic to `dsh-acp-agent`). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier.
|
||||
Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent <path/to/cordis.yml>`, the human channel). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier.
|
||||
|
||||
Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server".
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client
|
||||
* convention, wins) or the `argv[2]` positional path (the human channel,
|
||||
* isomorphic to `dsh-acp-agent`); an empty value counts as absent. Neither
|
||||
* for direct launches); an empty value counts as absent. Neither
|
||||
* given, or the path missing on disk, prints the one-line usage to stderr
|
||||
* and exits 1. No built-in fallback — the external config IS the deployment
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
|
||||
7
packages/ui/permission/README.md
Normal file
7
packages/ui/permission/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary.
|
||||
|
||||
A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched.
|
||||
|
||||
Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's default tree](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over.
|
||||
41
packages/ui/permission/package.json
Normal file
41
packages/ui/permission/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-permission",
|
||||
"description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
237
packages/ui/permission/src/index.ts
Normal file
237
packages/ui/permission/src/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* User-facing PERMISSION PRESETS: one product-level knob over the two
|
||||
* mechanism knobs. A preset names a bundle — its sandbox mode
|
||||
* (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a
|
||||
* user picks `workspace-write` or `danger-full-access` while the mechanism
|
||||
* tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event
|
||||
* records the chosen bundle (the audit fact reverse-mapping cannot recover —
|
||||
* two presets may share knob values and differ only in composed policy, the
|
||||
* planned `agent` preset being the standing example), then each knob event
|
||||
* follows through its own THE-write-path setter, skipping values the session
|
||||
* already effectively has. Every existing consumer (executor stamping, the
|
||||
* approval gate, narrators, resume) keeps reading its own knob fold,
|
||||
* untouched.
|
||||
*
|
||||
* @module dsh-permission
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
permission: PermissionService
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's permission preset was switched — log-only (the
|
||||
* `bash/sandbox-mode` precedent): durable and replayable, never in the
|
||||
* model transcript. The LAST such event is the session's preset
|
||||
* ({@link effectivePermissionPreset}); the knob events the switch wrote
|
||||
* through follow it in the same turn, and they — not this record of the
|
||||
* user's choice — are what execution reads.
|
||||
*/
|
||||
'permission/preset': { preset: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One preset's knob bundle — the sandbox mode and approval policy a session
|
||||
* runs under while the preset is active — plus its presentation.
|
||||
*/
|
||||
export interface PresetSpec {
|
||||
/** The `bash/sandbox-mode` value the preset writes through. */
|
||||
sandbox: SandboxMode
|
||||
/** The `approval/policy` value the preset writes through. */
|
||||
approval: ApprovalPolicy
|
||||
/** The display label a client shows for this preset; the raw table key when omitted. */
|
||||
name?: string
|
||||
/** One user-facing sentence on what the preset means; omitted when not configured. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
|
||||
export interface PresetOption {
|
||||
/** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */
|
||||
value: string
|
||||
/** The display label. */
|
||||
name: string
|
||||
/** One user-facing sentence on what the value means. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived not-a-preset state: the session's effective knob values match
|
||||
* no table entry (composition defaults outside the table, or a knob moved
|
||||
* out from under the last-chosen preset). Never a switch target and never
|
||||
* an event payload — {@link PermissionService.current} derives it, and the
|
||||
* presentation layer shows it as a selectable-FROM-only current value.
|
||||
*/
|
||||
export const CUSTOM_PRESET = 'custom'
|
||||
|
||||
/**
|
||||
* The session's permission-preset override: the last `permission/preset` event in the
|
||||
* log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the preset of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'permission/preset') return event.data.preset
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The {@link PermissionService} config: the deployment's preset table. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The preset table: name → knob bundle. Defaults to `workspace-write`
|
||||
* (workspace-write + ask) and `danger-full-access` (danger-full-access +
|
||||
* never). The name `custom` is reserved for the derived not-a-preset state.
|
||||
*/
|
||||
presets?: Record<string, PresetSpec>
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission service (`ctx.permission`). Owns the deployment's preset
|
||||
* table and THE write path for preset switches; presentation layers (the ACP
|
||||
* bridge's single `Permissions` select) advertise {@link names} and call
|
||||
* {@link set}. Composing it REQUIRES both mechanism knobs — a confining
|
||||
* `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no
|
||||
* table entry is not an error but the derived {@link CUSTOM_PRESET} state:
|
||||
* shown as the current value, never a switch target.
|
||||
*/
|
||||
export class PermissionService extends Service {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
static Config: z<Config> = z.object({
|
||||
presets: z.dict(z.object({
|
||||
sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
|
||||
approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})).default({
|
||||
// Keep the user-facing preset names explicit about filesystem reach.
|
||||
'workspace-write': {
|
||||
sandbox: 'workspace-write', approval: 'ask',
|
||||
name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.',
|
||||
},
|
||||
'danger-full-access': {
|
||||
sandbox: 'danger-full-access', approval: 'never',
|
||||
name: 'danger-full-access', description: 'Full file access, no approval prompts.',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
static inject = ['bash', 'approval']
|
||||
|
||||
private readonly presets: Record<string, PresetSpec>
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'permission')
|
||||
// The schema defaulted the table — the cast records that runtime fact.
|
||||
this.presets = config.presets as Record<string, PresetSpec>
|
||||
if (CUSTOM_PRESET in this.presets) {
|
||||
throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
|
||||
}
|
||||
if (ctx.bash.sandboxMode === undefined) {
|
||||
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The advertised preset names, in the preset table's declaration order.
|
||||
* @returns every switchable preset name.
|
||||
*/
|
||||
get names(): readonly string[] {
|
||||
return Object.keys(this.presets)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset a session is on right now, derived from the EFFECTIVE knob
|
||||
* values (fold ?? composition default per knob): the last-chosen preset
|
||||
* when its bundle still matches (presets may share bundles — the fold
|
||||
* breaks the tie), else the first table entry that matches, else
|
||||
* {@link CUSTOM_PRESET} — a mismatch is a state, not an error.
|
||||
* @param events - the session's events in log order.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
*/
|
||||
current(events: readonly SessionEvent[]): string {
|
||||
const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
|
||||
const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
|
||||
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
|
||||
const folded = effectivePermissionPreset(events)
|
||||
if (folded !== undefined) {
|
||||
const spec = this.presets[folded]
|
||||
if (spec !== undefined && matches(spec)) return folded
|
||||
}
|
||||
for (const [name, spec] of Object.entries(this.presets)) {
|
||||
if (matches(spec)) return name
|
||||
}
|
||||
return CUSTOM_PRESET
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset's knob bundle, for consumers presenting or validating one.
|
||||
* @param name - the preset name to resolve.
|
||||
* @returns the bundle; throws on a name outside the table (fails loud —
|
||||
* an unvalidated caller handed the service an unknown preset).
|
||||
*/
|
||||
resolve(name: string): PresetSpec {
|
||||
const spec = this.presets[name]
|
||||
if (spec === undefined) {
|
||||
throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
/**
|
||||
* The select-option presentation of one advertisable value: a table entry
|
||||
* (label/description from its spec, the raw key standing in for a missing
|
||||
* label) or the derived {@link CUSTOM_PRESET} with its fixed presentation.
|
||||
* @param name - a table key, or `custom`.
|
||||
* @returns the option a client renders; throws on any other name.
|
||||
*/
|
||||
optionOf(name: string): PresetOption {
|
||||
if (name === CUSTOM_PRESET) {
|
||||
return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }
|
||||
}
|
||||
const spec = this.resolve(name)
|
||||
return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a preset switch: appends one `permission/preset` event when
|
||||
* `name` differs from the session's current preset, then writes each knob
|
||||
* through its own setter, skipping values the session already effectively
|
||||
* has — a net-zero switch appends nothing (the log records switches, not
|
||||
* select clicks).
|
||||
* @param session - the session the switch belongs to.
|
||||
* @param name - the preset to switch to (validated via {@link resolve}).
|
||||
*/
|
||||
set(session: Session, name: string): void {
|
||||
const spec = this.resolve(name)
|
||||
if (this.current(session.events) !== name) {
|
||||
session.append('permission/preset', { preset: name })
|
||||
}
|
||||
const events = session.events
|
||||
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
|
||||
setSandboxMode(session, spec.sandbox)
|
||||
}
|
||||
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(session, spec.approval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PermissionService
|
||||
147
packages/ui/permission/tests/permission.spec.ts
Normal file
147
packages/ui/permission/tests/permission.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
|
||||
/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */
|
||||
async function mounted(options: {
|
||||
config?: Config
|
||||
bashDefault?: SandboxMode | undefined
|
||||
approvalDefault?: ApprovalPolicy | undefined
|
||||
} = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' })
|
||||
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
|
||||
await ctx.plugin(PermissionService, options.config ?? {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */
|
||||
function freshSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
|
||||
describe('effectivePermissionPreset', () => {
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const session = freshSession('sess-fold')
|
||||
expect(effectivePermissionPreset(session.events)).toBeUndefined()
|
||||
session.append('permission/preset', { preset: 'danger-full-access' })
|
||||
session.append('permission/preset', { preset: 'workspace-write' })
|
||||
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PermissionService', () => {
|
||||
it('advertises the preset table in declaration order and resolves bundles', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
|
||||
expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
|
||||
})
|
||||
|
||||
it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-current')
|
||||
expect(ctx.permission.current(session.events)).toBe('workspace-write')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-custom')
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
// Switching FROM custom is an ordinary write-through; custom itself is
|
||||
// never a target.
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('composition defaults outside the table derive custom at zero events', async () => {
|
||||
const ctx = await mounted({ approvalDefault: 'never' })
|
||||
const session = freshSession('sess-defaults-custom')
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
})
|
||||
|
||||
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
|
||||
const ctx = await mounted({ config: { presets: {
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
|
||||
agentish: { sandbox: 'workspace-write', approval: 'ask' },
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
|
||||
} } })
|
||||
const session = freshSession('sess-tie')
|
||||
// Same bundle as workspace-write, chosen explicitly: the fold names it.
|
||||
ctx.permission.set(session, 'agentish')
|
||||
expect(ctx.permission.current(session.events)).toBe('agentish')
|
||||
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('set() writes through: one preset event plus both knob events', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-set')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(session.events.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-noop')
|
||||
ctx.permission.set(session, 'workspace-write')
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-drift')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
// A knob drifts out from under the preset (a direct setter call, a test
|
||||
// scenario): the session derives custom, and re-asserting the preset is
|
||||
// a real switch again — choice re-recorded, only the drifted knob moves.
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
const tail = session.events.slice(4)
|
||||
expect(tail.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects composition over a non-confining executor at load', async () => {
|
||||
await expect(mounted({ bashDefault: undefined }))
|
||||
.rejects.toThrow(/does not confine/)
|
||||
})
|
||||
|
||||
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' })
|
||||
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' })
|
||||
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
|
||||
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
|
||||
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('rejects a table entry named custom (reserved for the derived state)', async () => {
|
||||
await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } }))
|
||||
.rejects.toThrow(/reserved for the derived not-a-preset state/)
|
||||
})
|
||||
|
||||
it('reads a schema-less approval stand-in as the ask default', async () => {
|
||||
const ctx = await mounted({ approvalDefault: undefined })
|
||||
const session = freshSession('sess-standin')
|
||||
ctx.permission.set(session, 'workspace-write')
|
||||
expect(session.events).toHaveLength(0)
|
||||
expect(ctx.permission.current(session.events)).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
33
packages/ui/permission/tsconfig.json
Normal file
33
packages/ui/permission/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user