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:
kingwl
2026-07-22 02:13:39 +08:00
773 changed files with 24980 additions and 4428 deletions
+6
View File
@@ -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"
@@ -30,6 +35,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-mode": "^0.0.1",
+11 -44
View File
@@ -47,10 +47,15 @@ import {
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
installAgentLlmTarget,
type Agent,
type AgentLlmTarget as LlmTarget,
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
@@ -267,19 +272,6 @@ export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
})
/** Provider/model pair selected for one ACP session. */
interface LlmTarget {
provider: string
model: string
}
/** Mutable target shared by one agent's scoped assembly and request listeners. */
interface LlmTargetRef {
current: LlmTarget | undefined
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
assembled: LlmTarget | undefined
}
/** One resolved ACP model selector plus its opaque value lookup. */
interface ModelDirectory {
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
@@ -354,32 +346,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
// Capture once at assembly entry and apply the same pair after downstream
// prompt listeners. A selector change during async assembly therefore takes
// effect on the following step instead of splitting {{model}} from routing.
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
})
installAgentLlmTarget(agentCtx, target)
}
/** Opaque ACP value preserving both routing dimensions. */
@@ -740,7 +707,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
// injection turns leave the switch pending because they execute no request.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
const rec = ownedRecord(agent)
if (rec !== undefined) flushPendingSwitches(rec)
return next()
@@ -1035,7 +1002,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// session/cancel maps to the queue-aware agent.cancel({ kind: 'user' }): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, while a prompt accepted afterward
@@ -1050,7 +1017,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (rec.commandAbort !== undefined) {
rec.commandAbort.abort(new Error('session/cancel'))
} else {
rec.agent.cancel('session/cancel')
rec.agent.cancel({ kind: 'user' })
settlePrompt(rec, 'cancelled')
}
return Promise.resolve()
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-acp`.
* @module @deepseek-ai/dsh-acp/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
/** Cordis companion plugin name. */
export const name = 'acp-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's 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))
/* jscpd:ignore-end */
+1 -1
View File
@@ -15,7 +15,7 @@ describe('turnEndToStopReason', () => {
it('maps every known TurnEndReason kind to a legal StopReason', () => {
expect(turnEndToStopReason({ kind: 'completed' })).toBe('end_turn')
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
+13 -3
View File
@@ -8,7 +8,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
@@ -25,6 +28,13 @@ class SandboxedLocalExecutor extends LocalBashExecutor {
}
}
async function mountInvariants(ctx: BridgeHarness['ctx']): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
function permissionOption(currentValue: string): object {
return {
id: 'permission',
@@ -76,7 +86,7 @@ describe('acp bridge — session config options', () => {
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
// Make an out-of-turn switch fail in this suite.
await harness.ctx.plugin(Invariants)
await mountInvariants(harness.ctx)
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await harness.ctx.plugin(ApprovalService)
await harness.ctx.plugin(PermissionService)
@@ -176,7 +186,7 @@ describe('acp bridge — session config options', () => {
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _next) => ({
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
...callConfig,
provider: 'mock',
model: 'mock',
+4
View File
@@ -325,6 +325,10 @@ describe('acp bridge — turn outcomes', () => {
await harness.client.cancel({ sessionId })
const res = await promptDone
expect(res.stopReason).toBe('cancelled')
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await agent.whenIdle()
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
+3
View File
@@ -55,6 +55,9 @@
},
{
"path": "../../bash/bash"
},
{
"path": "../../support/invariants"
}
]
}