Merge remote-tracking branch 'origin/master' into feat/plan-mode
Master arrivals re-grafted onto the plan-mode surfaces: - Package invariant runtime contracts: every package owns ./invariant — added the dsh-mode companion (mode/set payload validation: non-empty trimmed name), its unit spec, exports/peer wiring, and the mode group in the tsconfig invariant paths glob. - Interception waterfalls gained an AbortSignal parameter (agent/prompt-submit, agent/turn-continuation) and ToolExecutionInput.signal became required: listeners and test dispatch updated; the exit tool now forwards exec.signal unconditionally. - TUI question dialog: master's position/unanswered header layout kept, the plan-review detail block re-grafted between question and answers. - LSP capability family arrived: lsp tool joins the catalog pin, dsh-lsp examples deps beside dsh-mode; lsp-definition snapshot refreshed with the mode command and modes advertisement. - docs/architecture.md and packages/README.md regenerated tables re-grafted (ctx.modes row, mode package row) and condensed within word ceilings. - examples/acp-agent cordis.yml: kept the deployment-owned plan instructions (incl. the conversational-agreement sentence) beside master's routed-model compaction comment; snapshot expected outputs refreshed keyless and noise fixtures restored.
This commit is contained in:
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -40,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -231,7 +231,7 @@ export class ModesService extends Service {
|
||||
// flushed mode therefore lands before the prompt that should reflect it.
|
||||
// Contained: policy must never block a prompt or turn; onBoundary can throw
|
||||
// only when session.append rejects during teardown.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
@@ -239,7 +239,7 @@ export class ModesService extends Service {
|
||||
}
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/turn-continuation', (agent, _turn, _decision, next) => {
|
||||
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) => {
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
@@ -335,7 +335,7 @@ export class ModesService extends Service {
|
||||
],
|
||||
}],
|
||||
agent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
|
||||
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
|
||||
|
||||
48
packages/mode/mode/src/invariant.ts
Normal file
48
packages/mode/mode/src/invariant.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Package-owned durable mode-stream invariants. @module @deepseek-ai/dsh-mode/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-mode'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'mode-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Validate one `mode/set` payload before it reaches the durable log: the mode
|
||||
* is a non-empty bare name (config-declared vocabulary, not an opaque id), so
|
||||
* an empty or non-string value can only be a writer bug — folding it would
|
||||
* silently select the default mode while the log claims otherwise.
|
||||
*/
|
||||
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type !== 'mode/set') return
|
||||
const mode = (event.data as { mode?: unknown }).mode
|
||||
if (typeof mode !== 'string' || mode.trim() === '' || mode.trim() !== mode) {
|
||||
fail(`mode/set carries invalid mode ${JSON.stringify(mode)}; expected a non-empty trimmed name`)
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install validation for loaded and newly appended mode selections. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the mode invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
53
packages/mode/mode/tests/invariant.spec.ts
Normal file
53
packages/mode/mode/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as ModeInvariant from '@deepseek-ai/dsh-mode/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(ModeInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function event(mode: unknown): SessionEvent {
|
||||
return { type: 'mode/set', seq: 0, time: 0, data: { mode } } as SessionEvent
|
||||
}
|
||||
|
||||
describe('mode stream invariants', () => {
|
||||
it('accepts a plain non-empty trimmed mode name', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event('plan')) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event('default')) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[42, /invalid mode 42/],
|
||||
['', /invalid mode ""/],
|
||||
[' plan ', /invalid mode " plan "/],
|
||||
])('rejects an invalid durable mode selection', async (mode, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event(mode)) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('ignores unrelated dispatches and session events', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', {} as Session, {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an invalid existing selection on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('mode/set', { mode: '' })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(ModeInvariant).then(() => undefined)).rejects.toThrow(/invalid mode ""/)
|
||||
})
|
||||
})
|
||||
@@ -63,11 +63,11 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type:
|
||||
const events = agentEvents(ctx, agent)
|
||||
if (type === 'turn/start') {
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' }))
|
||||
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
|
||||
return
|
||||
}
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' }))
|
||||
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
|
||||
}
|
||||
|
||||
/** Dispatch the closed-step recovery seam with one terminal decision. */
|
||||
@@ -116,6 +116,7 @@ function execute(ctx: Context, name: string, agent?: Agent) {
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: {},
|
||||
signal: new AbortController().signal,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
@@ -586,6 +587,7 @@ describe('exit_plan_mode', () => {
|
||||
callId: CallId(`call-exit-${++callCounter}`),
|
||||
name: EXIT_PLAN_MODE,
|
||||
arguments: { plan },
|
||||
signal: new AbortController().signal,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
@@ -690,6 +692,7 @@ describe('exit_plan_mode', () => {
|
||||
callId: CallId(`call-exit-${++callCounter}`),
|
||||
name: RUN_CODE_NAME,
|
||||
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` },
|
||||
signal: new AbortController().signal,
|
||||
agent,
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user