Merge remote-tracking branch 'origin/master' into feat/profile-plugin-management

# Conflicts:
#	apps/cli/src/headless.ts
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-06 06:45:23 +08:00
783 changed files with 15211 additions and 14441 deletions

View File

@@ -148,10 +148,17 @@ describe('loadProfile', () => {
const home = tmp()
expect(() => loadProfile('t', 'custom', anchor, home))
.toThrow('profile "custom" does not exist')
// The web template exists but its bundles are not installed in this fake
// installation: init succeeds, resolution then fails loud on the bundle.
// The web template auto-initializes on first load. Bundle resolution
// cannot be asserted to fail here: the source-plane test runner resolves
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
expect(() => loadProfile('t', 'web', anchor, home)).toThrow('cannot resolve profile bundle')
try {
loadProfile('t', 'web', anchor, home)
} catch {
// Resolution failure is the plain-Node outcome for this empty anchor.
}
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.plugins)
.toEqual([...PROFILE_TEMPLATES.web ?? []])
})
it('fails loud when a listed bundle declares no dsh.patch', () => {

View File

@@ -396,7 +396,7 @@ describe('CommandService', () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('mid'))
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.session.append('turn/start', { turn: 1 })
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
expect(agent.session.events.map(event => event.type)).toEqual([
'turn/start', 'command/run', 'command/done',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: ac47af28e69e647ba44a7718478db163d406f5dc
README.zh.md: c023ffdc4462f673b83d993f57eba35594598843
README.md: 9cd4876b52f9527745b27041eb2555408c73fabd
README.zh.md: a2b979da448c04c0578382889be2ae3ea6076166

View File

@@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Config
`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
## stdout is the protocol
@@ -18,11 +18,11 @@ Stdout carries only JSON-RPC frames. The deployment must not compose a stdout lo
## Shutdown and exit semantics
The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process.
The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process.
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`.
## Model Experience
@@ -42,6 +42,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown, and one accepted prompt runs to agent idle before that session accepts another.
- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown.
- **There is no per-prompt result** — `MessageId` identifies inbox admission only; clients that own an automation interval must define and observe that interval themselves.
- **stdout purity is deployment-enforced** — a surrounding config can still load a stdout logger and corrupt the JSON-RPC channel; this plugin does not inspect or veto sibling loggers.
- **Automatic adapter mounting is DeepSeek-specific** — `initialize` can reuse any pre-registered model adapter, but its only fallback mounts `dsh-llm-deepseek`.

View File

@@ -10,7 +10,7 @@
## 配置
`maxTokensAsSuccess` 默认为 `false`。对于需要区分「因 token 上限而结束但可接受的 agent 结果」与「基础设施故障」的评测宿主,请将其设为 `true`。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。
`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。
## stdout 即协议
@@ -18,11 +18,11 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
## 关闭与退出语义
插件响应 `shutdown`,将 SDK 持有的 agent 和订阅 dispose(资源释放)至完全停稳,关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
插件响应 `shutdown`,刷新响应并 dispose(资源释放)根上下文,使 SDK 持有的 agent、订阅和持久化全部停稳,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者也会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验
@@ -42,6 +42,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
## 已知限制与暂缓事项
- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。
- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭。
- **没有逐提示词结果**:`MessageId` 只标识 inbox 准入;拥有自动化活动区间的客户端必须自行定义并观察该区间。
- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。
- **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。

View File

@@ -2,7 +2,7 @@
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable Agent Note and package README.
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
* This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin
* owns EOF and signal exits. Keep named plugin exports with no default export so
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
*
@@ -40,14 +40,15 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
* SDK-created agents and closes the transport. A `shutdown` response is flushed
* before this plugin's fiber is disposed and the process exits 0; the app bin
* before the root runtime is disposed and the process exits 0; the app bin
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Cordis applies the schema default before invoking the plugin.
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
// The later transport callback must dispose this plugin's fiber, not its ambient context.
const fiber = ctx.fiber
// Protocol shutdown owns the complete runtime process, so it must await the
// root lifecycle (including persistence) before exiting.
const rootFiber = ctx.root.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
const input = config.input ?? process.stdin
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
@@ -60,12 +61,13 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
// Share one exit task and attempt flush and disposal independently before exiting.
// Share one exit task so racing shutdown requests cannot dispose the root or
// exit the process more than once.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())])
await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())])
exit(0)
})()
return exitTask

View File

@@ -10,7 +10,7 @@ import { resolve } from 'node:path'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -19,7 +19,6 @@ import type {
InitializeResult,
JsonRpcTransportPeer,
SessionEventNotification,
SessionFinishedNotification,
SessionPromptParams,
SessionPromptResult,
SubagentFinishedNotification,
@@ -28,8 +27,6 @@ import type {
interface SessionRecord {
handle: AgentHandle
lastTurnEnd: TurnEndReason | undefined
activePrompt: boolean
}
/** Recover the delegating parent from the service-owned scoped carrier. */
@@ -72,15 +69,12 @@ export class HarnessSdkServer {
) {
const serverOptions = this.options
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
const rec = this.sessions.get(String(session.id))
if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) {
rec.lastTurnEnd = event.data.reason
}
}
const payload: SessionEventNotification = { sessionId: String(session.id), event }
this.transport.notify('session.event', payload)
}))
this.disposers.push(ctx.on('agent/status', (agent, status) => {
this.transport.notify('session.status', { sessionId: String(agent.session.id), status })
}))
this.disposers.push(ctx.on('session/created', (session) => {
const parentSession = session.header.parentSession
if (parentSession === undefined) return
@@ -131,34 +125,21 @@ export class HarnessSdkServer {
}
/**
* Run one prompt to settlement; overlap on the same session fails.
* Queue one identified prompt without assigning later activity to it.
* @param params - target session and user content.
* @returns acceptance after the turn settled.
* @returns the durable message identity.
*/
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
// An agent-loop-only reload disposes the loop's agents while this record
// survives; a retained agent accepts followup() silently, so validate the
// record against the live registry before delivery (as the ACP bridge does).
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
}
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
await rec.handle.agent.whenIdle()
const payload: SessionFinishedNotification = {
sessionId: params.sessionId,
status: this.finishedStatus(rec.lastTurnEnd),
reason: rec.lastTurnEnd,
}
this.transport.notify('session.finished', payload)
return { accepted: true }
} finally {
rec.activePrompt = false
}
const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(message)
return { messageId: message.id }
}
/**
@@ -244,16 +225,11 @@ export class HarnessSdkServer {
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
},
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
const rec: SessionRecord = { handle }
this.sessions.set(sessionId, rec)
return rec
}
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return successStatus(reason.kind, this.options)
}
private hasAdapterFor(provider: string): boolean {
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
}

View File

@@ -20,6 +20,7 @@ import * as jsonrpc from '../src/index.ts'
type WireEvent =
| { kind: 'frame'; frame: Record<string, unknown> }
| { kind: 'write-complete'; ids: (string | number)[] }
| { kind: 'root-disposed' }
| { kind: 'exit'; code: number }
interface ApplyHarness {
@@ -99,6 +100,7 @@ async function mountPlugin(
output.on('error', (error: Error) => { outputErrors.push(error) })
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness')
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
const frames = (): Record<string, unknown>[] =>
@@ -185,7 +187,12 @@ describe('dsh-jsonrpc plugin apply', () => {
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] },
})
const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response')
expect(response.result).toEqual({ accepted: true })
expect((response.result as { messageId?: unknown }).messageId).toBeTypeOf('string')
await harness.waitForFrame(
frame => frame.method === 'session.status'
&& (frame.params as { status?: string } | undefined)?.status === 'idle',
'idle session status',
)
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
@@ -195,9 +202,9 @@ describe('dsh-jsonrpc plugin apply', () => {
// Notifications use the same transport and arrive as id-less frames.
const notifications = harness.frames().filter(frame => frame.id === undefined)
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({
expect(notifications.findLast(frame => frame.method === 'session.status')).toMatchObject({
jsonrpc: '2.0',
params: { sessionId: 'main', status: 'ok' },
params: { sessionId: 'main', status: 'idle' },
})
} finally {
await harness.dispose()
@@ -224,16 +231,19 @@ describe('dsh-jsonrpc plugin apply', () => {
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed')
expect(firstResponse).toBeGreaterThanOrEqual(0)
expect(secondResponse).toBeGreaterThanOrEqual(0)
expect(firstComplete).toBeGreaterThan(firstResponse)
expect(secondComplete).toBeGreaterThan(secondResponse)
expect(flushComplete).toBeGreaterThan(firstComplete)
expect(flushComplete).toBeGreaterThan(secondComplete)
expect(exitIndex).toBeGreaterThan(flushComplete)
expect(rootDisposed).toBeGreaterThan(flushComplete)
expect(exitIndex).toBeGreaterThan(rootDisposed)
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
@@ -254,6 +264,7 @@ describe('dsh-jsonrpc plugin apply', () => {
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
@@ -279,6 +290,7 @@ describe('dsh-jsonrpc plugin apply', () => {
})
await harness.fiber.dispose()
expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })

View File

@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -127,12 +127,13 @@ describe('HarnessSdkServer', () => {
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
await server.handleRequest('session/prompt', {
const receipt = await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'fix it' }],
})
expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string')
expect(llmServer.requests).toHaveLength(1)
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
expect(body.model).toBe('dsagent-model')
expect(body.max_tokens).toBe(321)
@@ -140,16 +141,18 @@ describe('HarnessSdkServer', () => {
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
expect(transport.notifications.at(-1)).toMatchObject({
method: 'session.finished',
params: { sessionId: 'main', status: 'ok' },
await vi.waitFor(() => {
expect(transport.notifications.findLast(n => n.method === 'session.status')).toEqual({
method: 'session.status',
params: { sessionId: 'main', status: 'idle' },
})
})
await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'again' }],
})
expect(llmServer.requests).toHaveLength(2)
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) })
const orphanHandle = await ctx.agents.create({
sessionId: SessionId('orphan-session'),
@@ -168,24 +171,17 @@ describe('HarnessSdkServer', () => {
}
})
it('rejects overlapping prompts for one session without serializing other sessions', async () => {
let releaseMain: (() => void) | undefined
const firstMainIdle = new Promise<void>((resolve) => { releaseMain = resolve })
const mainWhenIdle = vi.fn<() => Promise<void>>()
.mockReturnValueOnce(firstMainIdle)
.mockResolvedValue(undefined)
it('queues overlapping prompts for one session without blocking other sessions', async () => {
const mainFollowup = vi.fn<Agent['followup']>()
const mainAgent = ({
id: SessionId('main'),
followup: mainFollowup,
whenIdle: mainWhenIdle,
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
} satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
const otherFollowup = vi.fn<Agent['followup']>()
const otherAgent = ({
id: SessionId('other'),
followup: otherFollowup,
whenIdle: vi.fn(() => Promise.resolve()),
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
} satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn(async (options: { sessionId: SessionId }) =>
@@ -202,20 +198,11 @@ describe('HarnessSdkServer', () => {
contentBlocks: [{ type: 'text', text }],
})
const first = prompt('main', 'first')
await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
expect((await prompt('main', 'first')).messageId).toBeTypeOf('string')
expect((await prompt('main', 'overlap')).messageId).toBeTypeOf('string')
expect((await prompt('other', 'independent')).messageId).toBeTypeOf('string')
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
releaseMain?.()
await expect(first).resolves.toEqual({ accepted: true })
await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true })
mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed'))
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
expect(mainFollowup).toHaveBeenCalledTimes(4)
expect(mainFollowup).toHaveBeenCalledTimes(2)
expect(otherFollowup).toHaveBeenCalledOnce()
await server.shutdown()
expect(mainHandle.dispose).toHaveBeenCalledOnce()
@@ -247,7 +234,7 @@ describe('HarnessSdkServer', () => {
contentBlocks: [{ type: 'text', text }],
})
await expect(prompt('while live')).resolves.toEqual({ accepted: true })
expect((await prompt('while live')).messageId).toBeTypeOf('string')
live = false
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
// The detached agent was never driven by the rejected prompt.
@@ -255,61 +242,26 @@ describe('HarnessSdkServer', () => {
await server.shutdown()
})
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
it('forwards whole-agent status without attributing a turn outcome', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport) as unknown as {
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
shutdown(): Promise<Record<string, never>>
}
const server = new HarnessSdkServer(ctx, transport)
const session = ctx.sessions.create(SessionId('message-outcome'))
const agent = ({
id: SessionId('message-outcome'),
session,
followup(input: UserMessage) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: input.source },
})
session.append('user/message', input, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 2,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
return input.id
},
whenIdle: () => Promise.resolve(),
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
ctx.agents.register(agent)
server.sessions.set('message-outcome', {
handle: { agent, dispose: () => Promise.resolve() },
lastTurnEnd: undefined,
activePrompt: false,
})
} satisfies Pick<Agent, 'id' | 'session'>) as Agent
await server.prompt({
sessionId: 'message-outcome',
contentBlocks: [{ type: 'text', text: 'bounded prompt' }],
})
ctx.emit('agent/status', agent, 'running')
ctx.emit('agent/status', agent, 'idle')
expect(transport.notifications.findLast(notification => notification.method === 'session.finished'))
.toEqual({
method: 'session.finished',
params: {
sessionId: 'message-outcome',
status: 'error',
reason: { kind: 'max-tokens' },
},
})
expect(transport.notifications.filter(notification => notification.method === 'session.status'))
.toEqual([
{ method: 'session.status', params: { sessionId: 'message-outcome', status: 'running' } },
{ method: 'session.status', params: { sessionId: 'message-outcome', status: 'idle' } },
])
await server.shutdown()
await ctx.fiber.dispose()
})
@@ -358,7 +310,7 @@ describe('HarnessSdkServer', () => {
contentBlocks: [{ type: 'text', text: 'hello' }],
})
expect(llmServer.requests).toHaveLength(1)
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -883,43 +835,6 @@ describe('HarnessSdkServer', () => {
},
)
it('classifies defensive finish states', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus(undefined)).toBe('error')
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('can report max-token turn termination as an accepted evaluation result', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context()
try {
@@ -1047,6 +962,6 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.shutdown()).rejects.toBe(listenerFailure)
expect(on).toHaveBeenCalledTimes(3)
expect(on).toHaveBeenCalledTimes(4)
})
})

View File

@@ -270,7 +270,7 @@ export class PermissionService extends Service {
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
return { kind: 'success', text: `preset ${name}` }
},
})
@@ -373,6 +373,11 @@ export class PermissionService extends Service {
* @param name - the preset to switch to; unknown names throw.
*/
set(session: Session, name: string): void {
this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
}
/** Apply one preset with the caller-selected live or initialization policy writer. */
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
const spec = this.resolve(name)
if (this.current(session.events) !== name) {
session.append('permission/preset', { preset: name })
@@ -382,7 +387,7 @@ export class PermissionService extends Service {
setSandboxMode(session, spec.sandbox)
}
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
setApprovalPolicy(session, spec.approval)
setApproval(spec.approval)
}
}

View File

@@ -220,7 +220,7 @@ describe('new-session default', () => {
defaultPreset: 'danger-full-access',
})
const legacy = freshSession('legacy-source')
legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
legacy.append('turn/start', { turn: 1 })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')

View File

@@ -9,7 +9,7 @@
* service removes the key (HMR safety).
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -19,6 +19,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import CommandService from '@deepseek-ai/dsh-commands'
import PermissionService from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
@@ -31,16 +32,17 @@ async function harness(options: { withPermission?: boolean; config?: Config } =
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
await ctx.plugin(ApprovalService)
if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
}
/** Mint a scoped agent over a live session (the command executor's addressing shape). */
async function agentFor(ctx: Context, session: Session): Promise<Agent> {
const agent = { id: session.id, session } as Agent
async function agentFor(ctx: Context, session: Session) {
const inject = vi.fn<Agent['inject']>()
const agent = { id: session.id, session, inject } as unknown as Agent
await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
return agent
return { agent, inject }
}
describe('permissions projection unit', () => {
@@ -62,7 +64,7 @@ describe('permissions projection unit', () => {
expect(changes).toHaveLength(3)
expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
// Unrelated event: same-reference apply, no notification.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(changes).toHaveLength(3)
})
@@ -87,17 +89,23 @@ describe('permissions projection unit', () => {
describe('/permission command', () => {
it('switches through permission.set and logs the lifecycle pair', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const { agent, inject } = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(inject.mock.calls[0]?.[0]).toMatchObject({
content: [{
type: 'text',
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
}],
})
const run = session.events.find(event => event.type === 'command/run')
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
})
it('reports the current preset and the table on bare invocation', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const { agent } = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
expect(execution?.result).toEqual({
kind: 'success',
@@ -108,7 +116,7 @@ describe('/permission command', () => {
it('rejects an unknown preset without touching the log', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const { agent } = await agentFor(ctx, session)
const before = session.events.filter(event =>
event.type !== 'command/run' && event.type !== 'command/done')
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)

View File

@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -59,8 +59,8 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the cache-safe runtime-context snapshot). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy}).
* from the runtime-context snapshot and live switch notices). The LAST
* such event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
*/
@@ -188,7 +188,7 @@ export interface Config {
/**
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
* changes to the model through the cache-safe runtime-context snapshot.
* changes to the model through the runtime-context snapshot and switch notices.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -217,6 +217,26 @@ export class ApprovalService extends Service {
})
}
/**
* Switch one live agent's policy and queue the transition for its next model
* step. Session initialization uses {@link setApprovalPolicy} directly
* because there is no previously visible policy to change.
* @param agent - the live agent whose policy is changing.
* @param policy - the new effective policy.
*/
setPolicy(agent: Agent, policy: ApprovalPolicy): void {
const previous = this.effectivePolicy(agent.session)
if (previous === policy) return
setApprovalPolicy(agent.session, policy)
agent.inject(createUserMessage({
content: [{
type: 'text',
text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
}],
source: { kind: 'plugin', plugin: 'user-approval' },
}))
}
/**
* Ask the composed answerers to decide one readonly same-process request.
* The service borrows the request, agent, session, and live signal directly.

View File

@@ -118,7 +118,7 @@ describe('ApprovalService.request', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('asked-observer-throw'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const agent = { session } as unknown as Agent
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.on('session/event', (_session, event) => {
@@ -141,7 +141,7 @@ describe('ApprovalService.request', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('decided-observer-throw'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const agent = { session } as unknown as Agent
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.on('session/event', (_session, event) => {
@@ -353,14 +353,14 @@ describe('approval policy (the approval/policy fold)', () => {
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */
/**
* An agent stand-in over a REAL Session — gate and context fold real events;
* the opened turn satisfies request()'s enclosure precondition.
*/
function sessionAgent(id: string): { agent: Agent; session: Session } {
const session = Session.create(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = {
id,
session,
} as unknown as Agent
session.append('turn/start', { turn: 1 })
const agent = { id, session } as unknown as Agent
return { agent, session }
}
@@ -448,6 +448,27 @@ describe('approval policy (the approval/policy fold)', () => {
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('queues a live policy switch for the next model step', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-policy-notice')
const inject = vi.fn<Agent['inject']>()
const liveAgent = { ...agent, inject } as Agent
ctx.approval.setPolicy(liveAgent, 'never')
ctx.approval.setPolicy(liveAgent, 'never')
expect(effectiveApprovalPolicy(session.events)).toBe('never')
expect(inject).toHaveBeenCalledOnce()
expect(inject.mock.calls[0]?.[0]).toMatchObject({
content: [{
type: 'text',
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
}],
source: { kind: 'plugin', plugin: 'user-approval' },
})
})
it('contributes the complete current ask or never policy as cache-safe context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -463,7 +484,7 @@ describe('approval policy (the approval/policy fold)', () => {
expect(await contextFor({})).toBe('')
})
it('reflects the latest durable switch and stays byte-stable while unchanged', async () => {
it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
@@ -479,13 +500,13 @@ describe('approval policy (the approval/policy fold)', () => {
expect(await contextFor()).toBe(NEVER_SENTENCE)
})
it('disposes the service context contribution with its fiber (HMR safety)', async () => {
it('disposes the runtime-context contribution with the service', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(ApprovalService)
const live = sessionAgent('sess-hmr-service-live')
const { agent } = sessionAgent('sess-hmr-service-live')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
(await ctx.systemPrompt.assemble({ agent })).contexts.find(context => context.name === 'approval:policy')
expect(await contextFor()).toBeDefined()
await fiber.dispose()
expect(await contextFor()).toBeUndefined()

View File

@@ -14,7 +14,7 @@ async function setup(): Promise<Context> {
}
function startTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
}
describe('approval invariants', () => {
@@ -32,7 +32,7 @@ describe('approval invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const id = ApprovalRequestId('ask-resume')
session.append('approval/asked', { id, toolName: 'bash' })
await ctx.plugin(InvariantService)
@@ -54,7 +54,7 @@ describe('approval invariants', () => {
expect(() => {
ctx.emit('session/event', session, {
type: 'turn/start', seq: 0, time: 0,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
})
ctx.emit('session/event', session, asked)
ctx.emit('session/event', session, decided)