Merge branch 'master' into jhz_compact_header

This commit is contained in:
Jinhua Zhu
2026-07-22 18:37:52 +08:00
83 changed files with 1830 additions and 584 deletions

View File

@@ -1143,8 +1143,8 @@ describe('workspace context request injection', () => {
})
it('keeps the direct provider API usable without an operation signal', async () => {
const root = '/virtual/no-signal-repo'
const home = '/virtual/no-signal-home'
const root = resolve('/virtual/no-signal-repo')
const home = resolve('/virtual/no-signal-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)

View File

@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
@@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
## Known Limitations and Deferred Work

View File

@@ -8,6 +8,9 @@
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
export type { ScopeLayer } from './store.ts'
/** An opaque, identity-compared scope key. */
export type ScopeKey = object

View File

@@ -0,0 +1,247 @@
/**
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
*
* @module @deepseek-ai/dsh-scope
*/
import type { Context } from 'cordis'
import { scopeOf } from './index.ts'
import type { ScopeKey } from './index.ts'
/** One scope's aggregate contribution to a registry. */
export interface ScopeLayer {
/** Whether every table in this layer is empty. */
isEmpty(): boolean
}
/** Internal common read contract for the two entry-table implementations. */
interface EntryValues<V> {
values(): IterableIterator<V>
isEmpty(): boolean
}
/**
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
*
* Values are borrowed. Iterators are live within one nonempty table
* generation; draining the table detaches them from later insertions. Each
* successful insertion returns an idempotent undo for that exact entry.
*/
export class NamedEntries<V> implements EntryValues<V> {
private data = new Map<string, V>()
constructor(
private readonly duplicateError: (name: string) => Error,
) {}
/**
* Insert one unique name.
* @param name - name unique within this table.
* @param value - borrowed value to retain.
* @returns an idempotent undo that removes only this insertion.
*/
insert(name: string, value: V): () => void {
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(name)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Read one named value.
* @param name - name to resolve.
* @returns the retained value, or `undefined` when absent.
*/
get(name: string): V | undefined {
return this.data.get(name)
}
/**
* Test one name for membership.
* @param name - name to test.
* @returns whether the table contains that name.
*/
has(name: string): boolean {
return this.data.has(name)
}
/**
* Iterate live names in insertion order.
* @returns the native live key iterator.
*/
keys(): IterableIterator<string> {
return this.data.keys()
}
/**
* Iterate live entries in insertion order.
* @returns the native live entry iterator.
*/
entries(): IterableIterator<[string, V]> {
return this.data.entries()
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Insertion-ordered anonymous entries with independent registration identity.
*
* Equal values remain separate registrations. Values are borrowed, and
* iterators are live within one nonempty table generation; draining the table
* detaches them from later appends.
*/
export class AnonymousEntries<V> implements EntryValues<V> {
private data = new Map<symbol, V>()
/**
* Append one independently owned value.
* @param value - borrowed value to retain.
* @returns an idempotent undo for this exact append.
*/
append(value: V): () => void {
const data = this.data
const key = Symbol()
data.set(key, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(key)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Own the global and exact-scope layers for one registry.
*
* Reads never create scoped layers. Registrations derive both visibility and
* effect ownership from the supplied Cordis context, collect undo before
* notification, and reclaim only a completely empty aggregate layer.
*/
export class ScopedLayers<L extends ScopeLayer> {
/** The eagerly constructed context-global layer. */
readonly global: L
private readonly scoped = new Map<ScopeKey, L>()
constructor(
private readonly createLayer: (scope: ScopeKey | undefined) => L,
private readonly onChange: () => void,
) {
this.global = createLayer(undefined)
}
/**
* Read an existing exact-scope overlay.
* @param scope - exact scope key; `undefined` denotes no overlay.
* @returns the existing scoped layer, or `undefined` without creating one.
*/
peek(scope: ScopeKey | undefined): L | undefined {
if (scope === undefined) return undefined
return this.scoped.get(scope)
}
/**
* Materialize global named entries followed by exact-scope shadows.
* @param scope - exact viewing scope, or `undefined` for the global view.
* @param pick - select the named table from a layer.
* @returns an insertion-ordered effective map.
*/
merge<V>(
scope: ScopeKey | undefined,
pick: (layer: L) => NamedEntries<V>,
): Map<string, V> {
const merged = new Map(pick(this.global).entries())
const layer = this.peek(scope)
if (layer === undefined) return merged
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
return merged
}
/**
* Attach one synchronous layer mutation to its registration context.
* @param ctx - context that determines both scope visibility and effect ownership.
* @param action - atomic mutation returning its synchronous undo.
* @param options - Cordis effect label and optional change notification.
* @returns the exact disposer returned by `ctx.effect()`.
*/
effect(
ctx: Context,
action: (layer: L) => () => void,
options: { label: string; notify?: boolean },
): () => void {
const scope = scopeOf(ctx)
const notify = options.notify ?? true
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
let layer: L
let created = false
if (scope === undefined) {
layer = this.global
} else {
const existing = this.scoped.get(scope)
if (existing === undefined) {
layer = this.createLayer(scope)
this.scoped.set(scope, layer)
created = true
} else {
layer = existing
}
}
let undo: () => void
try {
undo = action(layer)
} catch (error) {
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
throw error
}
yield () => {
undo()
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
if (notify) this.onChange()
}
if (notify) this.onChange()
}.bind(this), options.label)
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
return dispose
}
}

View File

@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
AnonymousEntries,
createScope,
NamedEntries,
ScopedLayers,
type Scope,
type ScopeKey,
type ScopeLayer,
} from '@deepseek-ai/dsh-scope'
class TestLayer implements ScopeLayer {
readonly named: NamedEntries<number>
readonly anonymous = new AnonymousEntries<string>()
constructor(scope: ScopeKey | undefined) {
this.named = new NamedEntries(name =>
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
}
isEmpty(): boolean {
return this.named.isEmpty() && this.anonymous.isEmpty()
}
}
/** Mint one active scope for lifecycle tests. */
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
let scope!: Scope
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
return scope
}
describe('NamedEntries', () => {
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
const duplicate = new Error('caller duplicate')
const duplicateError = vi.fn(() => duplicate)
const entries = new NamedEntries<number>(duplicateError)
const undoA = entries.insert('a', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
const undoB = entries.insert('b', 2)
expect([...values]).toEqual([2])
expect([...entries.keys()]).toEqual(['a', 'b'])
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
expect(entries.get('a')).toBe(1)
expect(entries.get('missing')).toBeUndefined()
expect(entries.has('b')).toBe(true)
expect(entries.has('missing')).toBe(false)
expect(entries.isEmpty()).toBe(false)
expect(() => entries.insert('a', 3)).toThrow(duplicate)
expect(duplicateError).toHaveBeenCalledWith('a')
undoA()
entries.insert('a', 3)
undoA()
expect(entries.get('a')).toBe(3)
undoB()
expect([...entries.entries()]).toEqual([['a', 3]])
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
const undo = entries.insert('first', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.insert('replacement', 2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('AnonymousEntries', () => {
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
const entries = new AnonymousEntries<object>()
const value = {}
const undoFirst = entries.append(value)
const values = entries.values()
expect(values.next()).toEqual({ value, done: false })
const undoSecond = entries.append(value)
expect([...values]).toEqual([value])
expect([...entries.values()]).toEqual([value, value])
undoFirst()
undoFirst()
expect([...entries.values()]).toEqual([value])
undoSecond()
expect(entries.isEmpty()).toBe(true)
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new AnonymousEntries<number>()
const undo = entries.append(1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.append(2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('ScopedLayers', () => {
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(scope) => {
created.push(scope)
return new TestLayer(scope)
},
vi.fn(),
)
const key = {}
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 2)
expect(created).toEqual([undefined])
expect(layers.peek(undefined)).toBeUndefined()
expect(layers.peek(key)).toBeUndefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
expect(created).toEqual([undefined])
})
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const changed = vi.fn()
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(selected) => {
created.push(selected)
return new TestLayer(selected)
},
changed,
)
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 1)
const removeNamed = layers.effect(
scope.ctx,
layer => layer.named.insert('shared', 2),
{ label: 'test.named', notify: false },
)
const removeTail = layers.effect(
scope.ctx,
layer => layer.named.insert('c', 3),
{ label: 'test.tail', notify: false },
)
const removeAnonymous = layers.effect(
scope.ctx,
layer => layer.anonymous.append('kept'),
{ label: 'test.anonymous', notify: false },
)
expect(created).toEqual([undefined, key])
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
expect(changed).not.toHaveBeenCalled()
removeNamed()
expect(layers.peek(key)).toBeDefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
removeTail()
expect(layers.peek(key)).toBeDefined()
removeAnonymous()
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
const ctx = new Context()
const events: string[] = []
const layers = new ScopedLayers(
scope => new TestLayer(scope),
() => void events.push('notify'),
)
const dispose = layers.effect(
ctx,
(layer) => {
events.push('action')
const undo = layer.named.insert('x', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.order' },
)
expect(events).toEqual(['action', 'notify'])
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
dispose()
dispose()
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
expect(layers.global.isEmpty()).toBe(true)
})
it('returns the exact context effect disposer', () => {
const rawDispose = vi.fn()
const effect = vi.fn(() => rawDispose)
const ctx = { effect } as unknown as Context
const action = vi.fn(() => vi.fn())
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
expect(returned).toBe(rawDispose)
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
expect(action).not.toHaveBeenCalled()
})
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
let failFactory = true
const layers = new ScopedLayers(
(selected) => {
if (selected !== undefined && failFactory) throw new Error('factory failed')
return new TestLayer(selected)
},
vi.fn(),
)
expect(() => layers.effect(
scope.ctx,
layer => layer.named.insert('never', 1),
{ label: 'store.factory', notify: false },
)).toThrow('factory failed')
expect(layers.peek(key)).toBeUndefined()
failFactory = false
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('action failed') },
{ label: 'store.action', notify: false },
)).toThrow('action failed')
expect(layers.peek(key)).toBeUndefined()
const dispose = layers.effect(
scope.ctx,
layer => layer.named.insert('kept', 1),
{ label: 'store.kept', notify: false },
)
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('second action failed') },
{ label: 'store.existing-action', notify: false },
)).toThrow('second action failed')
expect(layers.peek(key)?.named.get('kept')).toBe(1)
dispose()
await scope.dispose()
})
it('rolls back a scoped insertion when notification throws', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const events: string[] = []
let notifications = 0
const layers = new ScopedLayers(
selected => new TestLayer(selected),
() => {
events.push('notify')
if (++notifications === 1) throw new Error('change failed')
},
)
expect(() => layers.effect(
scope.ctx,
(layer) => {
const undo = layer.named.insert('rollback', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.rollback' },
)).toThrow('change failed')
expect(events).toEqual(['notify', 'undo', 'notify'])
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
})

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
return result + text.slice(last)
}
/** One tool-schema provider stored in a prompt layer. */
type ToolProvider = (context: AssembleContext) => ToolProviderResult
/** One prompt-variable provider stored in a prompt layer. */
type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
/**
* Create one prompt layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
}
/** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly layers = new ScopedLayers(
scope => new PromptLayer(scope),
() => { this.ctx.emit('system-prompt/change') },
)
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, config: Config) {
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
: this.scopedSections.get(scope) ?? (() => {
const created: PromptSection[] = []
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(section)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.sections.insert(section.name, section),
{ label: 'systemPrompt.section()' },
)
}
/**
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
* @returns the exact Cordis effect disposer.
*/
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.toolProviders
: this.scopedToolProviders.get(scope) ?? (() => {
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
this.scopedToolProviders.set(scope, created)
return created
})()
layer.push(provider)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.toolProviders.append(provider),
{ label: 'systemPrompt.tools()' },
)
}
/**
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.variableProviders
: this.scopedVariableProviders.get(scope) ?? (() => {
const created = new Map<string, (context: AssembleContext) => string | undefined>()
this.scopedVariableProviders.set(scope, created)
return created
})()
if (layer.has(name)) {
throw new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`)
}
layer.set(name, provider)
// Install rollback before notifying listeners that may throw.
yield () => {
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.variables.insert(name, provider),
{ label: 'systemPrompt.variable()' },
)
}
/**
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
const scope = context.scope
// Scoped variables shadow globals.
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
for (const [name, provider] of this.layers.global.variables.entries()) {
variables[name] = provider(context)
}
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
for (const [name, provider] of scopedVariables ?? []) {
const scopedVariables = this.layers.peek(scope)?.variables
for (const [name, provider] of scopedVariables?.entries() ?? []) {
variables[name] = provider(context)
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
const sectionByName = this.layers.merge(scope, layer => layer.sections)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
...this.layers.global.toolProviders.values(),
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it('shadows a global section before evaluating either text provider', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const globalText = vi.fn(() => 'global text')
const scopedText = vi.fn(() => 'scoped text')
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
expect(globalText).not.toHaveBeenCalled()
expect(scopedText).toHaveBeenCalledOnce()
})
})
describe('scoped variables', () => {
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
const again = await mintScope(ctx, 'child2')
again.ctx.systemPrompt.variable('v', () => '3')
})
it('defers a scoped variable that replaces the last provider in its generation', async () => {
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
const calls: string[] = []
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
calls.push('first')
dispose()
scope.ctx.systemPrompt.variable('mode', () => {
calls.push('replacement')
return 'replacement'
})
return 'first'
})
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
expect(calls).toEqual(['first'])
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
expect(calls).toEqual(['first', 'replacement'])
})
})
describe('scoped tool providers and toolOrder × restriction', () => {

View File

@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
})
it('snapshots tool-provider membership before evaluating an assembly', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.tools(() => {
if (!added) {
added = true
ctx.systemPrompt.tools(() => ({
schemas: [{ name: 'late', description: '', parameters: {} }],
}))
}
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
})
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
})
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
})
it('live-iterates variables registered by an earlier provider', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.variable('first', () => {
if (!added) {
added = true
ctx.systemPrompt.variable('late', () => 'second value')
}
return 'first value'
})
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
first: 'first value',
late: 'second value',
})
})
it('rejects a duplicate variable name and an unreferenceable name', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -463,9 +463,40 @@ interface ToolView {
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** One guard registration; the wrapper preserves independent duplicate registrations. */
interface ToolGuardRegistration {
guard: ToolGuard
/** One scope's complete tool-registry contribution. */
class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`))
}
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
}
/** Whether every compiled restriction in this layer admits a global tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
|| (filter.deny !== undefined && filter.deny.has(name))) return false
}
return true
}
/** First monotonic denial from this layer's live guard registrations. */
guardReason(exec: ToolExecution): string | undefined {
for (const guard of this.guards.values()) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
return undefined
}
}
/** Approval decision plus whether the approval channel reported cancellation. */
@@ -509,13 +540,10 @@ export class ToolRegistry extends Service {
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
@@ -593,7 +621,6 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
@@ -603,26 +630,11 @@ export class ToolRegistry extends Service {
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(name)) {
throw new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`)
}
layer.set(name, definition)
// Install rollback before notifying listeners.
yield () => {
layer.delete(name)
// Drop empty scope layers.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
/**
@@ -655,22 +667,11 @@ export class ToolRegistry extends Service {
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const list = this.restrictions.get(scope) ?? []
this.restrictions.set(scope, list)
list.push(compiled)
yield () => {
const index = list.indexOf(compiled)
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.restrictions.append(compiled),
{ label: 'tools.restrict()' },
)
}
/**
@@ -684,63 +685,18 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the guard.
*/
guard(guard: ToolGuard): () => void {
const scope = scopeOf(this.ctx)
const registration = { guard }
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
layer.add(registration)
yield () => {
layer.delete(registration)
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
}
}.bind(this), 'tools.guard()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
if (!layer) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Get or create the guard layer for one agent scope. */
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
let layer = this.scopedGuards.get(scope)
if (layer === undefined) {
layer = new Set()
this.scopedGuards.set(scope, layer)
}
return layer
return this.layers.effect(
this.ctx,
layer => layer.guards.append(guard),
{ label: 'tools.guard()', notify: false },
)
}
/** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined {
for (const { guard } of this.globalGuards) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
}
return undefined
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.has(name))
&& (filter.deny === undefined || !filter.deny.has(name)))
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
}
/**
@@ -752,18 +708,18 @@ export class ToolRegistry extends Service {
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
const layer = scope === undefined ? undefined : this.scoped.get(scope)
const layer = this.layers.peek(scope)
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.global) {
for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name)
restrictableNames.add(name)
if (this.admits(scope, name)) visible.set(name, definition)
if (layer?.admits(name) ?? true) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer ?? []) {
for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name)
visible.set(name, definition)
}

View File

@@ -266,6 +266,49 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0)
})
it('live-iterates a guard registered by an earlier guard', async () => {
const ctx = await mount()
const calls: string[] = []
let added = false
ctx.tools.register(tool('t'))
ctx.tools.guard(() => {
calls.push('first')
if (!added) {
added = true
ctx.tools.guard(() => {
calls.push('late')
return 'late denial'
})
}
return undefined
})
expect(await run(ctx, 't')).toBe('Error: late denial')
expect(calls).toEqual(['first', 'late'])
})
it('defers a scoped guard that replaces the last guard in its generation', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const calls: string[] = []
ctx.tools.register(tool('t'))
scope.ctx.tools.register(tool('scope_sibling'))
const lift = scope.ctx.tools.guard(() => {
calls.push('first')
lift()
scope.ctx.tools.guard(() => {
calls.push('replacement')
return 'replacement denial'
})
return undefined
})
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(calls).toEqual(['first'])
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
expect(calls).toEqual(['first', 'replacement'])
})
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')

View File

@@ -85,7 +85,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const located = await capture(dir())
expect(located.payload.transcript_path).toBe(located.expected)
expect((await capture()).payload.transcript_path).toBe('')
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
const d = dir()

View File

@@ -76,7 +76,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const located = await capture(dir())
expect(located.payload.transcript_path).toBe(located.expected)
expect((await capture()).payload.transcript_path).toBeNull()
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
const d = dir()

View File

@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
## What it does
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration

View File

@@ -8,7 +8,7 @@
*/
import type { ChildProcessByStdio } from 'node:child_process'
import { spawn } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { encodeMessage, MessageDecoder } from './framing.ts'
@@ -36,6 +36,132 @@ interface Pending {
reject: (error: Error) => void
}
/**
* Write one JSON-RPC message to the child stdin.
* @param stdin - the spawned server stdin.
* @param message - the unencoded JSON-RPC message.
* @param done - callback that reports asynchronous stream settlement.
*/
export type ConnectionWriter = (
stdin: Writable,
message: unknown,
done: (error?: Error | null) => void,
) => void
/** Host operations used to signal a detached process tree. */
export interface ProcessTreeOperations {
/** Signal a POSIX process group. */
readonly signal: (target: number, signal: NodeJS.Signals) => void
/** Signal the direct child when POSIX group signaling is unavailable. */
readonly killChild: (signal: NodeJS.Signals) => void
/** Terminate a Windows process tree by root pid. */
readonly taskkill: (pid: number) => void
}
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
export interface TaskkillResult {
/** Process exit status, or null when spawning failed. */
readonly status: number | null
/** Spawn failure, when the executable could not run. */
readonly error?: Error
}
/** Invoke a command synchronously for the Windows taskkill adapter. */
export type TaskkillRunner = (
command: string,
args: string[],
options: { stdio: 'ignore' },
) => TaskkillResult
/** Invoke the host process-signal primitive for a POSIX process group. */
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
/** taskkill status for "process not found": the requested process tree is already absent. */
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
/**
* Terminate one Windows process tree and wait for taskkill to finish.
* @param pid - root process id.
* @param run - command runner; tests inject results without requiring Windows.
*/
export function taskkillProcessTree(
pid: number,
run: TaskkillRunner = spawnSync,
): void {
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
if (result.error !== undefined) throw result.error
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
}
/**
* Signal one POSIX process group through an injectable host primitive.
* @param target - negative process-group id.
* @param signal - requested signal.
* @param run - host signal runner; tests inject it without touching real processes.
*/
export function signalProcessGroup(
target: number,
signal: NodeJS.Signals,
run: ProcessSignalRunner = processSignalRunner,
): void {
run(target, signal)
}
/**
* Wait until a process-tree liveness probe reports exit.
* @param isAlive - process-tree liveness probe.
* @param signal - optional bound for the wait.
* @param yieldNow - event-loop yield primitive.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
export async function waitForTreeExit(
isAlive: () => boolean,
signal?: AbortSignal,
yieldNow: () => Promise<unknown> = yieldToEventLoop,
): Promise<boolean> {
while (isAlive()) {
if (signal?.aborted) return false
await yieldNow()
}
return true
}
/**
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
* child; Windows requires taskkill to reach the full tree.
* @param platform - host platform.
* @param pid - detached root process id.
* @param signal - requested termination signal.
* @param operations - host operations.
*/
export function signalProcessTree(
platform: NodeJS.Platform,
pid: number,
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
operations.signal(-pid, signal)
} catch {
try {
operations.killChild(signal)
} catch {
// The direct child already exited; teardown remains idempotent.
}
}
}
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
@@ -50,14 +176,16 @@ export class LspConnection {
/**
* @param spec - how to launch the server and answer its config requests.
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
private readonly spec: ConnectionSpec,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
this.child = spawn(spec.command, [...spec.args], {
cwd: spec.cwd,
env: spec.env,
@@ -94,6 +222,20 @@ export class LspConnection {
return this.stderr.toString('utf8')
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
get failed(): boolean {
return this.closeReason !== undefined
}
/**
* Test whether a caught error is this connection's retained fatal transport cause.
* @param error - error caught by the instance or provider.
* @returns `true` only when this connection produced that exact failure.
*/
failedWith(error: unknown): boolean {
return this.closeReason === error
}
/**
* Send a request and await its result.
* @param method - the JSON-RPC method.
@@ -147,50 +289,38 @@ export class LspConnection {
return this.nextId
}
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
/** Request termination of the server's process tree. */
terminate(): void {
this.signalGroup('SIGTERM')
this.signalTree('SIGTERM')
}
/** Send SIGKILL to the server's process group. */
/** Force termination of the server's process tree. */
kill(): void {
this.signalGroup('SIGKILL')
this.signalTree('SIGKILL')
}
/**
* Wait until the owned process group has no members.
* Wait until the owned process tree has exited.
* @param signal - optional bound for the wait.
* @returns `true` when the group exited, or `false` when the signal aborted first.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
while (this.processGroupAlive()) {
if (signal?.aborted) return false
await yieldToEventLoop()
}
return true
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
}
/**
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
* direct child if the group send fails. Never throws — teardown races process exit.
*/
private signalGroup(sig: NodeJS.Signals): void {
/** Signal the whole process tree. */
private signalTree(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
try {
process.kill(-pid, sig)
} catch {
// The group is gone (already exited) or could not be signalled; try the direct child.
try {
this.child.kill(sig)
} catch {
// Already dead; nothing to signal.
}
}
signalProcessTree(process.platform, pid, sig, {
signal: signalProcessGroup,
killChild: this.child.kill.bind(this.child),
taskkill: taskkillProcessTree,
})
}
/** Whether the detached process group still has at least one member. */
private processGroupAlive(): boolean {
/** Whether the detached tree's root or POSIX process group is still alive. */
private processTreeAlive(): boolean {
const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false
@@ -218,7 +348,7 @@ export class LspConnection {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error))
this.signalGroup('SIGKILL')
this.signalTree('SIGKILL')
return
}
for (const message of messages) this.dispatch(message)
@@ -293,7 +423,7 @@ export class LspConnection {
reject(error)
}
try {
this.child.stdin.write(encodeMessage(message), done)
this.writer(this.child.stdin, message, done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {

View File

@@ -2,9 +2,9 @@
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace realpath, serves transient-open queries
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no
* sandbox confinement.
* through it, and replaces a selected transport that fails before or during the next read-only
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* and trust their configured servers — no sandbox confinement.
*
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
* unregisters from `ctx.lsp` and tears down every live server.
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = this.instanceFor(workspace)
}
try {
return await instance.query(request, source, signal)
} catch (error) {
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
if (instance.dead) this.evictIfCurrent(workspace, instance)
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
}
}
})
}

View File

@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec } from './connection.ts'
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
readonly killGraceMs: number
}
/**
* Force-kill a process tree only when graceful termination did not make it exit.
* @param treeExited - whether the tree exited within its grace period.
* @param forceKill - forceful process-tree termination primitive.
*/
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
if (!treeExited) forceKill()
}
/**
* A single initialized server process. Not exported as a provider — the provider single-flights and
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
@@ -58,9 +67,10 @@ export class LspInstance {
/**
* @param spec - the launch, initialize, and teardown parameters.
* @param writer - optional connection writer used by transport conformance tests.
*/
constructor(private readonly spec: InstanceSpec) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
@@ -70,7 +80,16 @@ export class LspInstance {
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
get dead(): boolean {
return this.processClosed || this.disposed
return this.processClosed || this.disposed || this.connection.failed
}
/**
* Test whether a caught query error came from this instance's transport.
* @param error - error caught by the provider.
* @returns `true` only for the connection's retained fatal transport cause.
*/
isTransportFailure(error: unknown): boolean {
return this.connection.failedWith(error)
}
/**
@@ -84,7 +103,12 @@ export class LspInstance {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
const run = abortable(this.queue, signal)
.then(() => this.runQuery(request, source, signal))
.catch(async (error: unknown) => {
if (this.isTransportFailure(error)) await this.startTeardown()
throw error
})
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
// on the wait does not deserialize the queue.
@@ -272,7 +296,7 @@ export class LspInstance {
try {
await this.gracefulShutdown(shutdownDeadline.signal)
} catch {
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
} finally {
shutdownDeadline[Symbol.dispose]()
}
@@ -286,20 +310,20 @@ export class LspInstance {
await abortable(this.connection.closed, signal)
}
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let groupExited: boolean
let treeExited: boolean
try {
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
if (!groupExited) this.connection.kill()
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([
this.connection.closed,
this.connection.waitForProcessGroupExit(),
this.connection.waitForProcessTreeExit(),
])
}
}

View File

@@ -1,6 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import {
signalProcessGroup,
signalProcessTree,
taskkillProcessTree,
waitForTreeExit,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type {
ConnectionWriter,
ProcessSignalRunner,
ProcessTreeOperations,
TaskkillRunner,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
})
it('treats signaling an already-closed child as a teardown race', async () => {
const conn = connectScript('')
await conn.closed
expect(() => { conn.kill() }).not.toThrow()
})
it('answers a server workspace/configuration request from static config', async () => {
const seen: SeenRequest[] = []
const conn = connect(
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
})
/** Spawn a raw connection running an inline node script as the "server". */
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: ['-e', script],
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
maxMessageBytes: 16_000_000,
maxStderrBytes,
configuration: null,
}, () => Promise.resolve(null))
}, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
})
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
await new Promise<void>(resolve => setTimeout(resolve, 100))
const timeout = new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
})
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
const failure = new Error('fixture stdin failure')
const writer: ConnectionWriter = (_stdin, _message, done) => {
queueMicrotask(() => { done(failure) })
}
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
})
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
@@ -230,6 +248,72 @@ describe('LspConnection edge behavior', () => {
})
})
describe('process-tree signaling', () => {
it('forwards POSIX process-group signals through the host runner', () => {
const run: ProcessSignalRunner = vi.fn(() => true)
signalProcessGroup(-42, 'SIGKILL', run)
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('waits for tree exit and stops when its bound aborts', async () => {
const isAlive = vi.fn()
.mockReturnValueOnce(true)
.mockReturnValue(false)
const yieldNow = vi.fn(() => Promise.resolve())
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
expect(yieldNow).toHaveBeenCalledOnce()
const controller = new AbortController()
controller.abort()
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
})
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
const operations = fakeProcessTreeOperations()
signalProcessTree('win32', 42, 'SIGTERM', operations)
expect(operations.taskkill).toHaveBeenCalledWith(42)
expect(operations.signal).not.toHaveBeenCalled()
signalProcessTree('linux', 42, 'SIGKILL', operations)
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
const fallback = fakeProcessTreeOperations()
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
expect(fallback.killChild).not.toHaveBeenCalled()
})
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
const posixGone = fakeProcessTreeOperations()
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
})
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
taskkillProcessTree(42, success)
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
const spawnFailure = new Error('cannot spawn taskkill')
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
})
})
/** Create observable process-tree operations without touching host processes. */
function fakeProcessTreeOperations(): ProcessTreeOperations {
return {
signal: vi.fn(),
killChild: vi.fn(),
taskkill: vi.fn(),
}
}
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()

View File

@@ -16,8 +16,6 @@
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
@@ -28,7 +26,7 @@
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
*/
import { appendFileSync, closeSync } from 'node:fs'
import { appendFileSync } from 'node:fs'
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
@@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
@@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (method === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause()
if (closeStdinAfterInitialized) closeSync(0)
return
}
if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) {
if (hang) return
const reply = (): void => {
if (closeStdinAfterReply) closeSync(0)
if (errorReply) {
send({ id, error: { code: -32000, message: 'server refused the request' } })
} else {
@@ -202,6 +196,6 @@ function send(message: Record<string, unknown>): void {
// Keep the event loop alive.
process.stdin.resume()
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
if (pauseStdinAfterInitialized) {
setInterval(() => {}, 1000)
}

View File

@@ -92,7 +92,8 @@ describe('readHostSource', () => {
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
})
it('rejects a FIFO with no writer without blocking in open', async () => {
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')

View File

@@ -1,9 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
@@ -26,7 +29,11 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
function makeInstance(
env: Record<string, string> = {},
overrides: Partial<InstanceSpec> = {},
writer?: ConnectionWriter,
): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: [fixtureServer],
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
})
}, writer)
live.push(instance)
return instance
}
@@ -201,17 +208,25 @@ describe('LspInstance query and abort', () => {
})
it('terminates when stdin fails during the didOpen write', async () => {
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
// the instance must still become dead so its provider can replace it.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
}, failingWriter('textDocument/didOpen'))
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true)
})
it('awaits process exit before rejecting a request write failure', async () => {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
}, failingWriter('textDocument/definition'))
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
expect(processAlive(pid)).toBe(false)
})
it('rejects when the server lacks the operation capability', async () => {
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
@@ -228,8 +243,7 @@ describe('LspInstance query and abort', () => {
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
})
describe('LspInstance disposal', () => {
it('escalates only when the process tree survives its grace period', () => {
const forceKill = vi.fn()
escalateProcessTree(false, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
escalateProcessTree(true, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
})
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
const marker = join(root, 'helper.pid')
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
await first
} finally {
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
await waitForProcessExit(helperPid)
}
})
@@ -322,6 +345,26 @@ function processAlive(pid: number): boolean {
}
}
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
const started = Date.now()
while (processAlive(pid)) {
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Write normally except for one method whose callback receives a deterministic transport error. */
function failingWriter(method: string): ConnectionWriter {
return (stdin, message, done) => {
if ((message as { method?: unknown }).method === method) {
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
return
}
stdin.write(encodeMessage(message), done)
}
}
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now()

View File

@@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose()
})
it('rejects a non-utf-16 position encoding at initialize', async () => {
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
const marker = join(root, 'initialize-rejection-exit.log')
const ctx = await mount({
LSP_FAKE_ENCODING: 'utf-8',
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_MARKER: marker,
})
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
await ctx.fiber.dispose()
})

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => {
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
env: { PATH: `::${join(root, 'empty')}` },
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose()
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
await ctx.fiber.dispose()
})
it('rejects an absolute command that is not executable at load', async () => {
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
const notExe = join(root, 'not-exe.txt')
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
@@ -13,7 +13,7 @@ import {
} from '@deepseek-ai/dsh-tool-lsp'
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = '/home/u/proj'
const WS = resolve('/home/u/proj')
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
@@ -52,8 +52,9 @@ describe('renderUri', () => {
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const uri = pathToFileURL('/other/lib/b.ts').href
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
const uri = pathToFileURL(outside).href
expect(renderUri(uri, WS)).toBe(outside)
})
it('renders the workspace root itself as "."', () => {
@@ -72,8 +73,8 @@ describe('renderUri', () => {
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
// An encoded path separator is invalid on every platform and must remain verbatim.
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
})
})

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -40,8 +42,11 @@ async function mount(
let seq = 0
const testToolSignal = new AbortController().signal
const workspaceRoot = resolve('/virtual/workspace')
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
const workspaceAlias = resolve('/virtual/workspace-alias')
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
return ctx.tools.execute({
signal: testToolSignal,
callId: `c-${++seq}` as never,
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/ws',
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: workspaceRoot,
}
describe('tool-lsp registration', () => {
@@ -107,40 +112,39 @@ describe('tool-lsp execution', () => {
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
const provider = stubProvider(() => okLocations)
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'goToDefinition',
filePath: 'a.ts',
position: { line: 2, character: 4 },
workspaceRoot: '/ws',
workspaceRoot,
})
})
it('renders locations relative to the workspace', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
// Relativizing against the alias would misclassify the location as external.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot,
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('renders hover content', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
})
@@ -153,14 +157,14 @@ describe('tool-lsp execution', () => {
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
})
it('returns a structured INVALID_ARGS on a bad operation', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('INVALID_ARGS')
})
@@ -176,7 +180,7 @@ describe('tool-lsp execution', () => {
},
}
const { ctx } = await mount(provider)
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
// undefined); the point is the tool threads it through without throwing.
expect(seen).toHaveLength(1)

View File

@@ -25,7 +25,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('globalConfigDir', () => {
it('prefers an explicit DSH_HOME override', () => {
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh'))
})
it('falls back to ~/.dsh when DSH_HOME is unset', () => {

View File

@@ -215,7 +215,8 @@ describe('cwd resolution', () => {
await ctx.fiber.dispose()
})
it('rejects a config cwd directory without search permission at load', async () => {
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
it.skipIf(process.platform === 'win32')('rejects a config cwd directory without search permission at load', async () => {
// statSync().isDirectory() is true for a mode-600 directory, but a
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))

View File

@@ -24,7 +24,7 @@ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
export type ExampleMode = 'src' | 'lib'
/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */
/** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */
export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
/**

View File

@@ -5,8 +5,8 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
@@ -68,6 +68,26 @@ interface RegisteredCommand {
readonly descriptor: CommandDescriptor
}
/** All command registrations owned by one global or scoped layer. */
class CommandLayer implements ScopeLayer {
readonly commands: NamedEntries<RegisteredCommand>
/**
* Create one command layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.commands = new NamedEntries(name => new Error(scope === undefined
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no command registrations. */
isEmpty(): boolean {
return this.commands.isEmpty()
}
}
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent.
*/
export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>()
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { this.notifyChange() },
)
constructor(ctx: Context) {
super(ctx, 'commands')
@@ -218,25 +240,12 @@ export class CommandService extends Service {
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(registered.definition.name)) {
throw new Error(scope === undefined
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${registered.definition.name}" is already registered in this scope`)
}
layer.set(registered.definition.name, registered)
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.notifyChange()
}
this.notifyChange()
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
return this.layers.effect(
this.ctx,
layer => layer.commands.insert(registered.definition.name, registered),
{ label: 'commands.register()' },
)
}
/**
@@ -285,19 +294,7 @@ export class CommandService extends Service {
/** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global)
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
return visible
}
/** Create the registration layer for one agent scope on demand. */
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
return this.layers.merge(agent, layer => layer.commands)
}
/** Notify every registry observer without making UI refresh load-bearing. */

View File

@@ -94,6 +94,19 @@ describe('CommandService', () => {
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
})
it('removes a registration when its contributing plugin fiber is disposed', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.commands.register(command('temporary'))
}, { inject: ['commands'] }))
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
await fiber.dispose()
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
})
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')

View File

@@ -320,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ inputTokens: 500, outputTokens: 8 },
{ turn: 3, step: 1 },
)
await tick()
await vi.waitFor(() => {
expect(result.terminal.output).toContain('final live answer')
})
expect(result.terminal.output).toContain('◒ Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
@@ -328,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.output).toContain('final live answer')
expect(result.terminal.progress).toContain(true)
result.session.append('assistant/chunk', {

View File

@@ -9,17 +9,15 @@ import {
} from '@deepseek-ai/dsh-web-search-deepseek'
/**
* Real-API smoke for the DeepSeek search provider. Self-skips without
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
* triggers native `web_search` and returns the structured result blocks the
* provider parses — a mock cannot confirm the wire shape is real.
* Disabled real-API probe for the DeepSeek search provider. The live endpoint
* can complete without structured source blocks, so this is not a reliable
* merge signal. Its body remains because mocks cannot confirm the wire shape.
*/
const apiKey = process.env.DEEPSEEK_API_KEY
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
maybe('DeepSeekSearchProvider real API', () => {
it('returns citeable sources for a live query via native web_search', async () => {
it.skip('returns citeable sources for a live query via native web_search', async () => {
const provider = new DeepSeekSearchProvider({
apiKey: apiKey!,
baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL,