From 4e50369eb6b1acb59670f57bb48cc8c2ef0831a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:37:41 +0800 Subject: [PATCH] feat: durable command lifecycle logging in the executor (command/run + command/done) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../command-goal/tests/command-goal.spec.ts | 33 +++-- .../plan/plan-mode/tests/plan-mode.spec.ts | 9 +- packages/ui/commands/README.i18n.yaml | 6 +- packages/ui/commands/README.md | 3 +- packages/ui/commands/README.zh.md | 3 +- packages/ui/commands/package.json | 1 + packages/ui/commands/src/index.ts | 113 ++++++++++++++++- packages/ui/commands/src/invariant.ts | 43 +++++-- packages/ui/commands/tests/commands.spec.ts | 119 +++++++++++++++++- packages/ui/commands/tsconfig.json | 3 + .../snapshots/disposed-terminal.expected.txt | 74 +++++------ .../snapshots/errors-and-help.expected.txt | 74 +++++------ .../status-diagnostics-narrow.expected.txt | 2 +- .../snapshots/status-diagnostics.expected.txt | 2 +- packages/ui/tui/tests/tui.spec.ts | 9 +- 15 files changed, 384 insertions(+), 110 deletions(-) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index d00a4d7887..cc0f681845 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -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 { 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() + 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>>> { 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 [|clear|edit |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 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 () => { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index c5371cba42..bcc88920a8 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -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 { - 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)) diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index ac4d257885..57c17b5823 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/README.i18n.yaml @@ -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 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 8fd49723c4..db3d06f395 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -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. diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index e2ad8ad80d..bb9b9d52c2 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -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 和类型化参数仍由各命令自行解析。 -- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。 - **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。 diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json index 0a777d22d8..6282f9ae08 100644 --- a/packages/ui/commands/package.json +++ b/packages/ui/commands/package.json @@ -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": { diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index ba8a519e4e..99f21ef334 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -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>() + 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( + session: Session, + type: T, + data: SessionEventMap[T], + ): Promise> { + 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. */ diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts index 87751d7cb4..858c31591c 100644 --- a/packages/ui/commands/src/invariant.ts +++ b/packages/ui/commands/src/invariant.ts @@ -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>() + const validateEvent = (session: Session, event: SessionEvent): void => { + if (event.type === 'command/run') { + const ids = runIds.get(session) ?? new Set() + 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 */ diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 7b6fefb2d2..d030b0830b 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -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 { 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/], diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json index 8f0448250f..470acd72df 100644 --- a/packages/ui/commands/tsconfig.json +++ b/packages/ui/commands/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/scope" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index b6fb49135b..7d2c06da75 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -11,46 +11,46 @@ buffer 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| -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: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " +4| " provider stream failed after partial output " style 1-43 fg=red -22| -23| " The previous process ended during this turn. " +5| +6| " The previous process ended during this turn. " style 1-44 fg=yellow -24| -25| " Unknown command: /unknown-advanced-command " +7| +8| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow +9| +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: [instructions] — load a skill into the conversation " + style 1-65 fg=bright-black 26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim 27| " " diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 05c35e32e1..524c620b2e 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -11,46 +11,46 @@ buffer 2| " deepseek-v4-flash • main-session" style 1-34 dim 3| -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: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " +4| " provider stream failed after partial output " style 1-43 fg=red -22| -23| " The previous process ended during this turn. " +5| +6| " The previous process ended during this turn. " style 1-44 fg=yellow -24| -25| " Unknown command: /unknown-advanced-command " +7| +8| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow +9| +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: [instructions] — load a skill into the conversation " + style 1-65 fg=bright-black 26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim 27| " " diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 4937592cc7..319a314c62 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -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 diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 915d4e58ef..3be6e24253 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -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 diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 232ef112d0..2424a409b6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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')