Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # docs/cordis-catalog/services.md # docs/core-data-structures/persistence.i18n.yaml # docs/persistence-catalog.md # packages/core/session/README.i18n.yaml # packages/sandbox/sandbox-policy/README.i18n.yaml # packages/subagent/subagent-inprocess/README.i18n.yaml
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 8fd49723c4b0534eebd2e590c647caadd63136a7
|
||||
README.zh.md: e2ad8ad80d002d769cf6a2c9f4f09c37ce960935
|
||||
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
|
||||
README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391
|
||||
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd
|
||||
|
||||
@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
@@ -37,5 +37,4 @@ Registry metadata, command input, and direct output never enter a model request
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
|
||||
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
|
||||
|
||||
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。
|
||||
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。
|
||||
|
||||
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
@@ -37,5 +37,4 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
|
||||
- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。
|
||||
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -28,12 +33,15 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
29
packages/ui/commands/src/brand.ts
Normal file
29
packages/ui/commands/src/brand.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* dsh-commands' owned branded id: command lifecycle pairing across the
|
||||
* session log, the wire admission response, and client-side flow pairing.
|
||||
*
|
||||
* The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; this module
|
||||
* is a pure type/constructor outlet (no cordis imports, no module
|
||||
* augmentation) so wire and client programs can name the brand without
|
||||
* loading the host plugin's Context merges — the `dsh-llm/brand` shape.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-commands/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Pairs one command execution's `command/run`/`command/done` lifecycle
|
||||
* records with each other and with the `command.execute` admission response.
|
||||
* Minted by the executor, monotonic per service instance.
|
||||
*/
|
||||
export type CommandId = Branded<'CommandId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link CommandId}.
|
||||
* @param id - the executor-minted pairing id.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function CommandId(id: string): CommandId {
|
||||
return id as CommandId
|
||||
}
|
||||
@@ -7,11 +7,28 @@ import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import { CommandId } from './brand.ts'
|
||||
|
||||
export { CommandId } from './brand.ts'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
|
||||
/**
|
||||
* Producer record for one command invocation (the `command/run` event's
|
||||
* provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s
|
||||
* shape; minimal today because every executor caller is a human-facing UI
|
||||
* surface dispatching a human-typed line, so the sole variant is `user`.
|
||||
*/
|
||||
export interface CommandSourceMap {
|
||||
user: { kind: 'user' }
|
||||
}
|
||||
|
||||
/** The union over {@link CommandSourceMap} — who issued a command line. */
|
||||
export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
|
||||
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
@@ -33,6 +50,19 @@ export type CommandResult =
|
||||
| { readonly kind: 'success'; readonly text?: string }
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/**
|
||||
* One settled command execution: the handler's normalized result plus the
|
||||
* lifecycle pairing id minted for its `command/run`/`command/done` records,
|
||||
* so a dispatching surface can correlate the RPC-level acknowledgment with
|
||||
* the flow node those events produce.
|
||||
*/
|
||||
export interface CommandExecution {
|
||||
/** Pairing id carried by this execution's lifecycle events. */
|
||||
readonly commandId: CommandId
|
||||
/** The handler's normalized outcome. */
|
||||
readonly result: CommandResult
|
||||
}
|
||||
|
||||
/** Plugin-owned command registration. */
|
||||
export interface CommandDefinition {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
@@ -88,6 +118,27 @@ class CommandLayer implements ScopeLayer {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* A resolved slash command entered its handler. Log-only (never model
|
||||
* surface); paired with `command/done` by `commandId`, mirroring the
|
||||
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
|
||||
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
|
||||
* separator whitespace included), so a consumer (a projection unit
|
||||
* folding its own command records, a rich command card) never re-parses
|
||||
* a line.
|
||||
*/
|
||||
'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource }
|
||||
/**
|
||||
* The paired command settled. `kind`/`text` carry the handler's verbatim
|
||||
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
|
||||
* rendered failure); presentation stays client-computed at render time.
|
||||
*/
|
||||
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
@@ -230,6 +281,11 @@ export class CommandService extends Service {
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
/** Monotonic per-instance counter behind {@link mintCommandId}. */
|
||||
private commandSeq = 0
|
||||
/** Instance token keeping minted ids unique across process restarts over one resumed log. */
|
||||
private readonly instanceToken = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
}
|
||||
@@ -272,24 +328,82 @@ export class CommandService extends Service {
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
*
|
||||
* A resolved command's lifecycle is logged: `command/run` is appended
|
||||
* before the handler is invoked and `command/done` after settlement (a
|
||||
* thrown or aborted handler settles as `kind: 'error'`). Both are direct
|
||||
* log-only appends — no turn wraps them, and persistence drains them at
|
||||
* ordinary checkpoints. Admission misses (syntax or unknown name) log
|
||||
* nothing — they never entered a handler. A `command/run` append failure
|
||||
* fails the execution loud; a `command/done` append failure on the
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
): Promise<CommandExecution | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const commandId = this.mintCommandId()
|
||||
this.appendLifecycle(agent.session, 'command/run', {
|
||||
commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' },
|
||||
})
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
let result: CommandResult
|
||||
try {
|
||||
const output = command.definition.handler(invocation)
|
||||
result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: 'error',
|
||||
text: error instanceof Error ? error.message : renderThrown(error),
|
||||
})
|
||||
} catch (appendError: unknown) {
|
||||
this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: result.kind,
|
||||
...result.text === undefined ? {} : { text: result.text },
|
||||
})
|
||||
return Object.freeze({ commandId, result })
|
||||
}
|
||||
|
||||
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
|
||||
private mintCommandId(): CommandId {
|
||||
this.commandSeq += 1
|
||||
return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one log-only lifecycle event directly: no turn is opened for it and
|
||||
* no flush is forced — persistence observes the eager `session/event` path
|
||||
* and drains at ordinary checkpoints and teardown, like every other
|
||||
* standalone plugin event.
|
||||
*/
|
||||
private appendLifecycle<T extends 'command/run' | 'command/done'>(
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
): SessionEvent<T> {
|
||||
// Both admitted types are log-only (non-surface), but TypeScript does not
|
||||
// reduce Session.append's conditional rest parameter through a generic
|
||||
// type parameter. Preserve the proven two-argument call shape.
|
||||
const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent<T>
|
||||
return appendLogOnly(type, data)
|
||||
}
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`:
|
||||
* command lifecycle events pair by commandId within one session log.
|
||||
* @module @deepseek-ai/dsh-commands/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
|
||||
|
||||
@@ -14,11 +15,36 @@ export const name = 'commands-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: registry notifications intentionally hide mutation details and contain
|
||||
* observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install pairing validation over loaded logs and newly appended lifecycle events. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
// Install-scoped so a dispose/re-register cycle re-sweeps from a clean slate.
|
||||
const runIds = new WeakMap<Session, Set<string>>()
|
||||
const validateEvent = (session: Session, event: SessionEvent): void => {
|
||||
if (event.type === 'command/run') {
|
||||
const ids = runIds.get(session) ?? new Set<string>()
|
||||
if (ids.has(event.data.commandId)) {
|
||||
fail(`command/run repeats commandId ${JSON.stringify(event.data.commandId)}`)
|
||||
}
|
||||
ids.add(event.data.commandId)
|
||||
runIds.set(session, ids)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'command/done') return
|
||||
if (runIds.get(session)?.has(event.data.commandId) !== true) {
|
||||
fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
|
||||
}
|
||||
}
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(session, event)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
validateEvent(session, event)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -27,4 +53,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
@@ -16,18 +16,27 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
|
||||
/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: name as SessionId } as Agent
|
||||
const session = ctx.sessions.create(SessionId(name))
|
||||
const agent = { id: session.id, session } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
/** The lifecycle slice of one agent's log (boundary markers stripped). */
|
||||
function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
|
||||
return agent.session.events
|
||||
.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => ({ type: event.type, data: event.data }))
|
||||
}
|
||||
|
||||
describe('parseCommand()', () => {
|
||||
it.each([
|
||||
['/goal', { name: 'goal', rawInput: '' }],
|
||||
@@ -87,11 +96,11 @@ describe('CommandService', () => {
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
@@ -167,10 +176,12 @@ describe('CommandService', () => {
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(execution?.commandId).toBeTruthy()
|
||||
expect(Object.isFrozen(execution)).toBe(true)
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
rawInput: ' untouched ',
|
||||
@@ -262,9 +273,9 @@ describe('CommandService', () => {
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
@@ -272,8 +283,8 @@ describe('CommandService', () => {
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
expect(silent?.result).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent?.result)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -286,6 +297,112 @@ describe('CommandService', () => {
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it('logs a paired command/run + command/done around a successful handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('deploy', 'deployed'))
|
||||
|
||||
const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
expect(lifecycle).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
||||
])
|
||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(ids[0]).toBeTruthy()
|
||||
expect(ids[0]).toBe(ids[1])
|
||||
// The execution's pairing id is the logged one (RPC-level correlation).
|
||||
expect(execution?.commandId).toBe(ids[0])
|
||||
// Direct log-only appends: no turn is opened for the pair on an idle log.
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it('mints distinct monotonic commandIds across executions', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('first'))
|
||||
ctx.commands.register(command('second'))
|
||||
await ctx.commands.execute(agent, '/first', new AbortController().signal)
|
||||
await ctx.commands.execute(agent, '/second', new AbortController().signal)
|
||||
const ids = lifecycleOf(agent)
|
||||
.filter(event => event.type === 'command/run')
|
||||
.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('logs command/done kind error for an expected error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
|
||||
await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'denied' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'not now' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'Throw',
|
||||
handler: () => { throw new Error('handler exploded') },
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
|
||||
.rejects.toThrow('handler exploded')
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'boom' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the signal aborts a hanging handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'Hang',
|
||||
handler: () => new Promise(() => undefined),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.commands.execute(agent, '/hang', controller.signal)
|
||||
// The run append must land before the abort so the pair stays complete.
|
||||
await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
|
||||
controller.abort('operator cancelled command')
|
||||
await expect(pending).rejects.toThrow('operator cancelled command')
|
||||
await vi.waitFor(() => {
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'hang' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('logs nothing for admission misses (syntax or unknown name)', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('real'))
|
||||
const signal = new AbortController().signal
|
||||
await ctx.commands.execute(agent, 'not a command', signal)
|
||||
await ctx.commands.execute(agent, '/missing', signal)
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
|
||||
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' } } })
|
||||
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 96dae46c9c6ecce6643bb408a5e57c2db2275a83
|
||||
README.zh.md: 2c257b13a15ad3c6c1bf0c4dd44a04e308e8f0b0
|
||||
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
|
||||
README.md: 48eb6106fe4b015f114b264a312249a92128266f
|
||||
README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303
|
||||
|
||||
@@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. 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 injection or plugin-owned zero-step turns 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`. 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`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
|
||||
|
||||
## 协议说明
|
||||
|
||||
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续注入或插件持有的零步骤轮次仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
|
||||
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1104,12 +1104,12 @@ export function createTuiChat(
|
||||
const controller = new AbortController()
|
||||
commandControllers.add(controller)
|
||||
void ctx.commands.execute(agent, text, controller.signal).then(
|
||||
(result) => {
|
||||
(execution) => {
|
||||
if (disposed) return
|
||||
if (result === undefined) {
|
||||
if (execution === undefined) {
|
||||
appendNotice(`Unknown command: ${text}`, 'warning')
|
||||
} else if (result.text !== undefined && result.text !== '') {
|
||||
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
|
||||
} else if (execution.result.text !== undefined && execution.result.text !== '') {
|
||||
appendNotice(execution.result.text, execution.result.kind === 'error' ? 'error' : 'info')
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=14 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
8| " dsh > @design "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 14-14 inverse
|
||||
9| " → Session · Searchable design re opaque-source-id · /workspace/project · 1970-01-01T0 "
|
||||
style 7-38 fg=bright-blue
|
||||
10-35| <blank>
|
||||
@@ -48,7 +48,7 @@ buffer
|
||||
17| "│ │"
|
||||
style 0-0 dim
|
||||
style 55-55 dim
|
||||
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
|
||||
18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 55-55 dim
|
||||
|
||||
@@ -45,7 +45,7 @@ buffer
|
||||
16| "│ │"
|
||||
style 0-0 dim
|
||||
style 81-81 dim
|
||||
17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
|
||||
17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │"
|
||||
style 0-0 dim
|
||||
style 3-12 fg=bright-black
|
||||
style 81-81 dim
|
||||
|
||||
@@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
@@ -35,6 +37,7 @@ const CHECKPOINTS = [
|
||||
'retry-exhausted',
|
||||
'banner-gradient',
|
||||
'file-autocomplete',
|
||||
'session-title-autocomplete',
|
||||
'code-mode-pending',
|
||||
'dynamic-workflow-pending',
|
||||
'cordis-tools-pending',
|
||||
@@ -399,6 +402,30 @@ describe('TUI terminal-state snapshots', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pins session autocomplete discovered through a log-backed title', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('opaque-source-id'), {
|
||||
meta: { cwd: '/workspace/project', createdAt: 1 },
|
||||
})
|
||||
source.append('session/title', {
|
||||
title: 'Searchable design review',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
})
|
||||
harness.terminal.send('@design')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await harness.terminal.snapshot()).toContain('Session · Searchable design re')
|
||||
})
|
||||
await checkpoint('session-title-autocomplete', harness.terminal)
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins Code Mode run_code with its production presenter', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
|
||||
@@ -1309,6 +1309,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
result.terminal.send('/clear')
|
||||
result.terminal.send('\r')
|
||||
await tick() // the executor logs command/run durably before the handler clears
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('answer after clear')
|
||||
@@ -2139,7 +2140,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('/workspace/status')
|
||||
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
|
||||
expect(result.terminal.output).toContain('hidden)')
|
||||
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
|
||||
// 6 domain events + the /status invocation's own command/run (open turn: joined directly).
|
||||
expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
|
||||
expect(result.terminal.output).toContain('1,250 input + 340 output')
|
||||
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
|
||||
expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
|
||||
@@ -2180,7 +2182,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
expect(result.terminal.output).toContain('untitled')
|
||||
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
|
||||
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
|
||||
// The /status invocation's command/run lands directly on the empty log — no turn wraps it.
|
||||
expect(result.terminal.output).toContain('idle · 1 event · 0 turns · 0 steps · 0 tool calls')
|
||||
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
|
||||
expect(result.terminal.output).toContain('7 used · capacity unknown')
|
||||
expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
|
||||
@@ -2232,8 +2235,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
for (const command of ['/clear', '/wat']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
await tick() // /clear's handler runs after the durable command/run append; keep it from wiping the next notice
|
||||
}
|
||||
await tick()
|
||||
result.terminal.send('draft')
|
||||
result.terminal.send('\x03')
|
||||
result.terminal.send('\x04')
|
||||
@@ -2271,21 +2274,50 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
|
||||
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
||||
let sourceId = SessionId('uninitialized')
|
||||
const sourceId = SessionId('source-session')
|
||||
const sourceHeader: SessionHeader = {
|
||||
version: 0,
|
||||
id: sourceId,
|
||||
cwd: '/workspace',
|
||||
createdAt: 1,
|
||||
}
|
||||
const noCwdHeader: SessionHeader = {
|
||||
version: 0,
|
||||
id: SessionId('no-cwd'),
|
||||
createdAt: 2,
|
||||
}
|
||||
const sourceEvents: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'session/title',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = await setup({
|
||||
sessionPersistence: {
|
||||
list: async () => [noCwdHeader, sourceHeader],
|
||||
load: async (id) => {
|
||||
if (id === sourceId) return { meta: sourceHeader, events: sourceEvents }
|
||||
if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] }
|
||||
throw new Error(`unexpected persisted session ${id}`)
|
||||
},
|
||||
},
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
sourceId = source.id
|
||||
appendUser(source, 'source background')
|
||||
source.append('session/title', {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2294,7 +2326,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('(no cwd)')
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('@source-session')
|
||||
result.terminal.send('@chat')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
|
||||
expect(result.terminal.output).toContain('source-session')
|
||||
result.terminal.send('\t')
|
||||
@@ -3011,12 +3043,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('advertised by multiple providers')
|
||||
expect(result.terminal.output).toContain('already alpha/a1')
|
||||
|
||||
const firstSelectorOutput = result.terminal.output.length
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Select model')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model')
|
||||
})
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
|
||||
|
||||
@@ -18,19 +18,26 @@ type ApprovalTransition =
|
||||
| { kind: 'asked'; id: ApprovalRequestId }
|
||||
| { kind: 'decided'; id: ApprovalRequestId }
|
||||
|
||||
interface ApprovalTrace {
|
||||
openTurn: number | null
|
||||
pending: Set<ApprovalRequestId>
|
||||
}
|
||||
|
||||
/** Validate one approval event against committed unmatched questions. */
|
||||
function validateApprovalEvent(
|
||||
pending: ReadonlySet<ApprovalRequestId>,
|
||||
trace: ApprovalTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): ApprovalTransition | undefined {
|
||||
if (event.type === 'approval/asked') {
|
||||
if (trace.openTurn === null) fail('approval/asked appended outside any open turn')
|
||||
if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty')
|
||||
if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
|
||||
if (trace.pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
|
||||
return { kind: 'asked', id: event.data.id }
|
||||
}
|
||||
if (event.type === 'approval/decided') {
|
||||
if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`)
|
||||
if (trace.openTurn === null) fail('approval/decided appended outside any open turn')
|
||||
if (!trace.pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`)
|
||||
if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) {
|
||||
fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`)
|
||||
}
|
||||
@@ -52,28 +59,39 @@ function applyApprovalTransition(pending: Set<ApprovalRequestId>, transition: Ap
|
||||
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
|
||||
/* jscpd:ignore-start */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, Set<ApprovalRequestId>>()
|
||||
const traces = new WeakMap<Session, ApprovalTrace>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; transition: ApprovalTransition }>()
|
||||
const seed = (session: Session): Set<ApprovalRequestId> => {
|
||||
const pending = new Set<ApprovalRequestId>()
|
||||
traces.set(session, pending)
|
||||
const seed = (session: Session): ApprovalTrace => {
|
||||
const trace: ApprovalTrace = { openTurn: null, pending: new Set() }
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
const transition = validateApprovalEvent(pending, event, fail)
|
||||
if (transition !== undefined) applyApprovalTransition(pending, transition)
|
||||
if (event.type === 'turn/start') trace.openTurn = event.data.turn
|
||||
else if (event.type === 'turn/end') trace.openTurn = null
|
||||
const transition = validateApprovalEvent(trace, event, fail)
|
||||
if (transition !== undefined) applyApprovalTransition(trace.pending, transition)
|
||||
}
|
||||
return pending
|
||||
return trace
|
||||
}
|
||||
const traceFor = (session: Session): Set<ApprovalRequestId> => traces.get(session) ?? seed(session)
|
||||
const traceFor = (session: Session): ApprovalTrace => traces.get(session) ?? seed(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const trace = traceFor(session)
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return
|
||||
}
|
||||
if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every package-owned pair event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation')
|
||||
staged.delete(event)
|
||||
applyApprovalTransition(traceFor(session), candidate.transition)
|
||||
applyApprovalTransition(trace.pending, candidate.transition)
|
||||
}, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
|
||||
@@ -13,10 +13,15 @@ async function setup(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function startTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}
|
||||
|
||||
describe('approval invariants', () => {
|
||||
it('accepts paired audit events and closed policy values', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
session.append('approval/decided', { id, outcome: 'allowed-once' })
|
||||
@@ -47,14 +52,43 @@ describe('approval invariants', () => {
|
||||
type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const },
|
||||
} as const
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
ctx.emit('session/event', session, asked)
|
||||
ctx.emit('session/event', session, decided)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects audit events outside any open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => session.append('approval/asked', {
|
||||
id: ApprovalRequestId('ask-1'), toolName: 'bash',
|
||||
})).toThrow(/outside any open turn/)
|
||||
expect(() => session.append('approval/decided', {
|
||||
id: ApprovalRequestId('ask-1'), outcome: 'rejected',
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an unenclosed audit event when replaying an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('approval/asked', {
|
||||
id: ApprovalRequestId('ask-replay'), toolName: 'bash',
|
||||
})
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(ApprovalInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects malformed and unpaired audit events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
expect(() => session.append('approval/asked', { id, toolName: '' }))
|
||||
.toThrow(/toolName must be non-empty/)
|
||||
|
||||
Reference in New Issue
Block a user