feat: durable command lifecycle logging in the executor (command/run + command/done)

CommandService.execute appends the log-only pair around every resolved
handler — run before invocation, done at settlement, including thrown and
aborted handlers (kind:'error'); admission misses log nothing. commandId is
minted monotonically per instance; per-session appends serialize through a
tail queue over SessionStore.appendOutOfBand (zero-step wrap on an idle log,
direct join inside an open turn). The invariant companion now asserts the
pairing relation (unique run ids; a done requires a prior in-log run).
CommandSource is a minimal merge-extensible map (user variant only).
Dependent benches mount SessionStore; TUI/e2e snapshots re-recorded for the
executor's durable-append timing and the /status event counts.
This commit is contained in:
imccyu
2026-07-27 17:37:41 +08:00
parent e900ebd4c6
commit 4e50369eb6
15 changed files with 384 additions and 110 deletions

View File

@@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
@@ -22,8 +22,9 @@ function appendInjection(session: Session, input: UserMessageData): void {
}
/** Build a live idle agent accepted by the exact-identity goal service. */
function stubAgent(id: string): { agent: Agent; session: Session } {
const session = new Session(SessionId(id))
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
@@ -45,15 +46,31 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
/** Mount the real command registry, goal domain, and producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const plugin = await ctx.plugin(commandGoal)
const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
/** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */
function domainEvents(session: Session): readonly Session['events'][number][] {
const lifecycle = new Set<number>()
for (const event of session.events) {
if (event.type !== 'command/run' && event.type !== 'command/done') continue
lifecycle.add(event.seq)
// The zero-step wrap around a lifecycle event is bookkeeping too.
const before = session.events[event.seq - 1]
const after = session.events[event.seq + 1]
if (before?.type === 'turn/start') lifecycle.add(before.seq)
if (after?.type === 'turn/end') lifecycle.add(after.seq)
}
return session.events.filter(event => !lifecycle.has(event.seq))
}
/** Execute `/goal` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const result = await test.ctx.commands.execute(
@@ -98,7 +115,7 @@ describe('/goal human command', () => {
kind: 'success',
text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
})
expect(test.session.events).toEqual([])
expect(domainEvents(test.session)).toEqual([])
})
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
@@ -110,14 +127,14 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
expect(test.session.events.map(event => event.type)).toEqual(['user/message'])
expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
const count = test.session.events.length
const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({
kind: 'error',
text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
})
expect(test.session.events).toHaveLength(count)
expect(domainEvents(test.session)).toHaveLength(count)
})
it('treats only exact control words as controls', async () => {

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
@@ -24,7 +24,9 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
const session = new Session(SessionId(id))
// A live store session when a store is mounted (the command executor logs
// lifecycle events through it); bare otherwise (fold/tool-only benches).
const session = ctx.get('sessions')?.create(SessionId(id)) ?? new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
@@ -488,6 +490,7 @@ describe('/plan', () => {
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
@@ -526,6 +529,7 @@ describe('/plan', () => {
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
const ctx = await setup()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await new Promise(resolve => setImmediate(resolve))
const signal = new AbortController().signal
@@ -564,6 +568,7 @@ describe('/plan', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))

View File

@@ -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: db3d06f395fc50c8a6cf5901e42f0b09e083a07e
README.zh.md: bb9b9d52c2fd0845b0795c37ba0155de319bae28

View File

@@ -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 `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the exact line, 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. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
`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.

View File

@@ -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()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、精确命令行和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
@@ -37,5 +37,4 @@
## 已知限制与延期工作
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。

View File

@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-agent": "^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": {

View File

@@ -7,11 +7,25 @@ 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'
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. */
@@ -88,6 +102,34 @@ class CommandLayer implements ScopeLayer {
}
}
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Zero-step turn opened only to durably record a command lifecycle event on an idle log. */
command: { kind: 'command' }
}
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. `line` is the exact command line as
* dispatched.
*/
'command/run': { commandId: string; name: string; line: 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: string; kind: 'success' | 'error'; text?: string }
}
interface OutOfBandSessionEventMap {
'command/run': true
'command/done': true
}
}
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -225,11 +267,25 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent.
*/
export class CommandService extends Service {
/** The executor writes lifecycle events through the session store. */
static inject = ['sessions']
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { 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)
/**
* Per-session lifecycle-append chains: `appendOutOfBand` rejects a second
* concurrent out-of-band append, so this service serializes its own writes
* (the session-title tail-queue pattern).
*/
private readonly logTails = new WeakMap<Session, Promise<void>>()
constructor(ctx: Context) {
super(ctx, 'commands')
}
@@ -272,6 +328,15 @@ export class CommandService extends Service {
/**
* Parse and execute a known command without sending it to the model.
*
* A resolved command's lifecycle is durably logged: `command/run` is
* appended before the handler is invoked and `command/done` after
* settlement (a thrown or aborted handler settles as `kind: 'error'`).
* 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.
@@ -287,9 +352,53 @@ export class CommandService extends Service {
const command = this.view(agent).get(parsed.name)
if (command === undefined) return undefined
if (signal.aborted) throw abortError(signal)
const commandId = this.mintCommandId()
await this.appendLifecycle(agent.session, 'command/run', {
commandId, name: parsed.name, line, 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 {
await 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
}
await this.appendLifecycle(agent.session, 'command/done', {
commandId, kind: result.kind,
...result.text === undefined ? {} : { text: result.text },
})
return result
}
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
private mintCommandId(): string {
this.commandSeq += 1
return `cmd-${this.instanceToken}-${this.commandSeq}`
}
/**
* Append one lifecycle event, serialized per session: `appendOutOfBand`
* rejects concurrent out-of-band appends, and two commands may overlap on
* one session.
*/
private appendLifecycle<T extends 'command/run' | 'command/done'>(
session: Session,
type: T,
data: SessionEventMap[T],
): Promise<SessionEvent<T>> {
const tail = this.logTails.get(session) ?? Promise.resolve()
const run = tail.then(() => this.ctx.sessions.appendOutOfBand(session, type, data, { kind: 'command' }))
this.logTails.set(session, run.then(() => undefined, () => undefined))
return run
}
/** Resolve global definitions followed by exact scoped shadows. */

View File

@@ -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 */

View File

@@ -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: '' }],
@@ -286,6 +295,110 @@ 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'))
await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
const lifecycle = lifecycleOf(agent)
expect(lifecycle).toMatchObject([
{ type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
])
const [run, done] = lifecycle as [{ data: { commandId: string } }, { data: { commandId: string } }]
expect(run.data.commandId).toBe(done.data.commandId)
// Zero-step wrap: the pair stays turn-enclosed on an idle log.
expect(agent.session.events.map(event => event.type)).toEqual([
'turn/start', 'command/run', 'turn/end',
'turn/start', 'command/done', 'turn/end',
])
})
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/],

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}

View File

@@ -11,46 +11,46 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
4| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
5| <blank>
6| " The previous process ended during this turn. "
style 1-44 fg=yellow
24| <blank>
25| " Unknown command: /unknown-advanced-command "
7| <blank>
8| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
9| <blank>
10| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
11| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
12| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
13| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
14| " "
15| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
16| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
17| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
18| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
19| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
20| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
21| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
22| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
23| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
24| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
25| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "

View File

@@ -11,46 +11,46 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
4| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
5| <blank>
6| " The previous process ended during this turn. "
style 1-44 fg=yellow
24| <blank>
25| " Unknown command: /unknown-advanced-command "
7| <blank>
8| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
9| <blank>
10| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
11| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
12| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
13| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
14| " "
15| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
16| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
17| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
18| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
19| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
20| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
21| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
22| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
23| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
24| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
25| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "

View File

@@ -52,7 +52,7 @@ buffer
18| "│ │"
style 0-0 dim
style 55-55 dim
19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
19| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim

View File

@@ -49,7 +49,7 @@ buffer
17| "│ │"
style 0-0 dim
style 81-81 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
18| "│ 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

View File

@@ -1281,6 +1281,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')
@@ -1758,7 +1759,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)')
@@ -1794,7 +1796,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')
// An empty log gains the /status invocation's zero-step wrap: turn/start + command/run + turn/end.
expect(result.terminal.output).toContain('idle · 3 events · 1 turn · 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')
@@ -1834,8 +1837,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')