Merge remote-tracking branch 'upstream/master' into fix/subprocess-password-scrub

This commit is contained in:
ZiyaZhang
2026-07-28 08:15:08 -07:00
628 changed files with 15274 additions and 6295 deletions

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: 4ad72cf9e232c8d41e525f42eecde5637032a391
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd

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 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.

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

View File

@@ -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:^",

View 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
}

View File

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

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: '' }],
@@ -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/],

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: 48eb6106fe4b015f114b264a312249a92128266f
README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 63615654769bf4ed7a69c09dc818af034c3a3c3c

View File

@@ -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 between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. 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

View File

@@ -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`可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验

View File

@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
@@ -56,6 +57,7 @@ export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
private model = 'deepseek'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
@@ -113,9 +115,14 @@ export class HarnessSdkServer {
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
if (params.maxTokens !== undefined
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
throw new TypeError('initialize maxTokens must be a positive safe integer')
}
this.cwd = resolve(params.cwd)
this.provider = params.provider
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
@@ -140,7 +147,7 @@ export class HarnessSdkServer {
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.followup({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
await rec.handle.agent.whenIdle()
const payload: SessionFinishedNotification = {
sessionId: params.sessionId,
@@ -231,7 +238,11 @@ export class HarnessSdkServer {
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { provider: this.provider, model: this.model },
agentOptions: {
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
},
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
this.sessions.set(sessionId, rec)

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
@@ -5,9 +6,9 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -122,6 +123,7 @@ describe('HarnessSdkServer', () => {
cwd: storageDir,
provider: 'deepseek',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
@@ -131,8 +133,9 @@ describe('HarnessSdkServer', () => {
})
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
expect(body.model).toBe('dsagent-model')
expect(body.max_tokens).toBe(321)
expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
@@ -153,7 +156,7 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
})
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
@@ -171,13 +174,13 @@ describe('HarnessSdkServer', () => {
const mainWhenIdle = vi.fn<() => Promise<void>>()
.mockReturnValueOnce(firstMainIdle)
.mockResolvedValue(undefined)
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
const mainFollowup = vi.fn<Agent['followup']>()
const mainAgent = ({
id: SessionId('main'),
followup: mainFollowup,
whenIdle: mainWhenIdle,
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
const otherFollowup = vi.fn<Agent['followup']>()
const otherAgent = ({
id: SessionId('other'),
followup: otherFollowup,
@@ -220,7 +223,7 @@ describe('HarnessSdkServer', () => {
})
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
const followup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('stub'))
const followup = vi.fn<Agent['followup']>()
const agent = ({
id: SessionId('zombie'),
followup,
@@ -266,7 +269,7 @@ describe('HarnessSdkServer', () => {
const agent = ({
id: SessionId('message-outcome'),
session,
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
followup(input: UserMessage) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: input.source },
@@ -277,12 +280,12 @@ describe('HarnessSdkServer', () => {
turn: 2,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
return AgentMessageId('message-outcome')
return input.id
},
whenIdle: () => Promise.resolve(),
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
@@ -859,6 +862,27 @@ describe('HarnessSdkServer', () => {
}
})
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid initialize maxTokens %s at the wire boundary',
async (maxTokens) => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
},
)
it('classifies defensive finish states', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
const ctx = await makeHarness(storageDir)
@@ -973,15 +997,18 @@ describe('HarnessSdkServer', () => {
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
expect(create).toHaveBeenCalledWith(expect.objectContaining({
meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
}))
await server.shutdown()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 528daef773635451ceb198ab3231c2dd87cb9413
README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2
README.md: 5aafd6f5207320bf273c96a04f2d606577ca2da0
README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5

View File

@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.

View File

@@ -12,7 +12,7 @@ DeepSeek Harness agent智能体的交互式终端入口基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect因此卸载会移除排队工作或在清理结算前关闭可见工作终端关闭会先卸载依赖项再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新`todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立`todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`

View File

@@ -99,7 +99,7 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
for (const block of event.data.message.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}

View File

@@ -423,7 +423,7 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}

View File

@@ -336,9 +336,10 @@ export class ToolCardComponent implements Component {
* @param event - The `tool/result` event payload.
*/
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
const result = event.message.content[0]
this.result = {
content: [...event.content],
isError: event.isError,
content: [...result.content],
isError: result.isError === true,
...event.meta !== undefined ? { meta: event.meta } : {},
}
if (this.parsed.valid && this.definition?.presentResult) {

View File

@@ -24,22 +24,21 @@ import {
assembleContextFor,
installAgentLlmTarget,
type Agent,
type AgentMessageId,
type AgentLlmTargetRef,
type AgentStatus,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-token-meter'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import { renderUnknownXml } from './components/xml-tool-output.ts'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
SessionId,
type SessionEvent,
type UserMessageData,
type UserMessage,
} from '@deepseek-ai/dsh-session'
import { foldGoal } from '@deepseek-ai/dsh-goal'
import {
@@ -280,7 +279,7 @@ export function createTuiChat(
// TUI steering submissions that the inbox has not yet claimed or discarded.
// Correlation ids avoid guessing whether a running-state submission actually
// joined steering or fell back to the queued-turn FIFO during turn close.
const pendingSteering = new Set<AgentMessageId>()
const pendingSteering = new Set<MessageId>()
let disposed = false
let shuttingDown: Promise<void> | undefined
// Optional: skills mount conditionally, so read the global service store
@@ -655,7 +654,7 @@ export function createTuiChat(
break
}
case 'steering/message': {
const text = displayText(contentText(event.data.content).trim())
const text = displayText(contentText(event.data.message.content).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
@@ -671,7 +670,7 @@ export function createTuiChat(
case 'assistant/message':
completedStreaming = undefined
if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data)
streaming?.settle(event.data.content)
streaming?.settle(event.data.message.content)
break
case 'llm/retry': {
retractFailedStreaming()
@@ -688,7 +687,8 @@ export function createTuiChat(
trailStreamingTiming()
break
case 'tool/result': {
let card = toolCards.get(event.data.callId)
const callId = event.data.message.source.callId
let card = toolCards.get(callId)
if (card === undefined) {
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
chat.addChild(new Spacer(1))
@@ -696,13 +696,17 @@ export function createTuiChat(
allToolCards.add(card)
}
card.updateResult(event.data)
toolCards.delete(event.data.callId)
toolCards.delete(callId)
trailStreamingTiming()
break
}
case 'todo/write':
todo.update(event.data.todos)
break
case 'turn/start':
// Plan strip is turn-scoped: keep it after turn/end for reading, clear on the next turn.
todo.update([])
break
case 'session/title':
sessionTitle = event.data.title
header.invalidate()
@@ -759,6 +763,7 @@ export function createTuiChat(
toolCards.clear()
allToolCards.clear()
streaming = undefined
todo.update([])
const active = activeSurfaceSeqs(agent.session)
const activeCalls = activeToolCallIds(agent.session, active)
for (const event of agent.session.events) {
@@ -1104,12 +1109,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) => {
@@ -1120,7 +1125,7 @@ export function createTuiChat(
).finally(() => { commandControllers.delete(controller) })
}
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessageData): void => {
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessage): void => {
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
return
@@ -1129,43 +1134,37 @@ export function createTuiChat(
// Steering is never subject to prompt admission; an attached snapshot
// drains beside it at the same step boundary through the outbox.
if (attachedContext !== undefined) {
agent.inject({ content: attachedContext.content, source: attachedContext.source })
agent.inject(attachedContext)
}
pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
const message = createUserMessage({ content, source: { kind: 'user' } })
agent.steer(message)
pendingSteering.add(message.id)
refreshStatus()
return
}
if (attachedContext === undefined) {
agent.followup({ content, source: { kind: 'user' } })
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
return
}
// Idle: the snapshot rides the prompt's admission transaction so a
// blocking hook discards both together.
let cleanedUp = false
let acceptedId: AgentMessageId | undefined
let acceptedContent: ContentBlock[] | undefined
const enqueued = new Map<AgentMessageId, ContentBlock[]>()
const discarded = new Set<AgentMessageId>()
const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
const acceptedId = message.id
const discarded = new Set<MessageId>()
const cleanup = (): void => {
// Every completion path detaches all three listeners. Keep this
// Every completion path detaches both listeners. Keep this
// idempotent so later cleanup paths cannot double-release them.
/* v8 ignore next -- unreachable idempotence guard, see above */
if (cleanedUp) return
cleanedUp = true
detachEnqueue()
detachSubmit()
detachDiscard()
}
// send() snapshots input before publishing it, and publishes enqueue
// before returning its id. Capture that snapshot by id so admission can
// use exact reference identity without depending on caller-owned input.
const detachEnqueue = ctx.on('agent/inbox/enqueue', (subject, message) => {
if (subject === agent) enqueued.set(message.id, message.content)
})
// Prepended so this wrapper is outermost: it observes the admission
// whether a downstream hook allows or blocks, and detaches either way.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _source, _signal, next) => {
if (subject !== agent || submitted !== acceptedContent) return next()
// Prepended so this wrapper is outermost: it observes the exact accepted
// message identity whether a downstream hook allows or blocks, then detaches.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
if (subject !== agent || submitted.id !== message.id) return next()
cleanup()
const decision = await next()
if (decision.kind !== 'allow') return decision
@@ -1176,15 +1175,13 @@ export function createTuiChat(
const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject !== agent) return
for (const message of messages) discarded.add(message.id)
if (acceptedId !== undefined && discarded.has(acceptedId)) cleanup()
if (discarded.has(acceptedId)) cleanup()
})
// followup() accepts any typed input and contains listener failures;
// this guards a future synchronous throw so the wrapper cannot leak.
/* v8 ignore start -- future-proofing guard, see above */
try {
acceptedId = agent.followup({ content, source: { kind: 'user' } })
acceptedContent = enqueued.get(acceptedId) ?? content
detachEnqueue()
agent.followup(message)
if (discarded.has(acceptedId)) cleanup()
} catch (error: unknown) {
cleanup()
@@ -1388,7 +1385,7 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const settlePendingSteering = (id: AgentMessageId): void => {
const settlePendingSteering = (id: MessageId): void => {
if (pendingSteering.delete(id)) refreshStatus()
}
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {

View File

@@ -1,7 +1,7 @@
import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-llm'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, {
AgentMessageId,
type Agent,
type AgentCancelCause,
type AgentOptions,
@@ -15,7 +15,7 @@ import type {
LlmResolvedModelInfo,
} from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -26,12 +26,13 @@ import TuiPromptService from '../src/prompt.ts'
interface FakeAgent extends Agent {
status: AgentStatus
sent: ContentBlock[][]
sentMessages: UserMessage[]
sentOptions: (SendOptions | undefined)[]
steered: ContentBlock[][]
steeredIds: AgentMessageId[]
steeredOptions: UserMessageData[]
steeredIds: MessageId[]
steeredOptions: UserMessage[]
injected: ContentBlock[][]
injectedOptions: UserMessageData[]
injectedOptions: UserMessage[]
cancelled: AgentCancelCause[]
}
@@ -181,12 +182,13 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
}
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const sentMessages: UserMessage[] = []
const steered: ContentBlock[][] = []
const steeredIds: AgentMessageId[] = []
const steeredIds: MessageId[] = []
const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: UserMessageData[] = []
const steeredOptions: UserMessage[] = []
const injected: ContentBlock[][] = []
const injectedOptions: UserMessageData[] = []
const injectedOptions: UserMessage[] = []
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
@@ -198,6 +200,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
},
ctx,
sent,
sentMessages,
sentOptions,
steered,
steeredIds,
@@ -207,25 +210,27 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
cancelled,
send(input, options) {
sent.push(input.content)
sentMessages.push(input)
sentOptions.push(options)
return AgentMessageId('stub')
return input.id
},
followup(input) {
sent.push(input.content)
sentMessages.push(input)
sentOptions.push(undefined)
return AgentMessageId('stub')
return input.id
},
steer(input) {
steered.push(input.content)
steeredOptions.push(input)
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
const id = input.id
steeredIds.push(id)
return id
},
inject(input) {
injected.push(input.content)
injectedOptions.push(input)
return AgentMessageId('stub')
return input.id
},
cancel(cause) {
cancelled.push(cause)
@@ -263,10 +268,10 @@ export async function disposeTuiTestHarness(
/** Append a production-shaped user message to the active session surface. */
export function appendUser(session: Session, text: string): void {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the active session surface. */
@@ -278,8 +283,11 @@ export function appendAssistant(
): void {
session.append('assistant/message', {
...position,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'deepseek-v4-flash' },
}),
...usage === undefined ? {} : { usage },
}, { surfaceOp: 'append' })
}

View File

@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -68,27 +68,33 @@ describe('TUI session-reference snapshot', () => {
const adapter = new SnapshotAdapter()
ctx.llm.registerAdapter(['mock'], adapter)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
const oldUser = source.append('user/message', {
const oldUser = source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const oldAssistant = source.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'mock' },
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
source.append('user/message', {
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
}), {
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
})
source.append('user/message', {
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Recent retained question.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const target = ctx.agentLoop.create(
SessionId('target-session'),

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=15 bufferRow=15
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| "Tracking the steps. "
6| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "Plan the work. "
10| <blank>
11| "You "
style 0-2 fg=bright-blue bold underline
12| "Next question. "
13| <blank>
14| "/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
15| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
16-35| <blank>

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } 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'
@@ -57,6 +57,7 @@ const CHECKPOINTS = [
'resume-sessions',
'status-diagnostics',
'status-diagnostics-narrow',
'todo-plan-cleared',
] as const
// Real-loop scenarios own their assertions in separate snapshot suites but
@@ -169,9 +170,11 @@ function appendToolResult(
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId(id),
content,
isError: options.isError ?? false,
message: createToolResultMessage({
callId: CallId(id),
content,
isError: options.isError ?? false,
}),
...options.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
}
@@ -299,6 +302,34 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('clears the plan strip when the next turn starts', async () => {
// Freeze Completed-at formatting: the first turn ends before the next starts,
// so the assistant timing line still appears without a Plan strip below it.
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 45, 0).getTime())
const harness = await setupSnapshot({
beforeMount(session) {
appendUser(session, 'Plan the work.')
appendAssistant(session, [{ type: 'text', text: 'Tracking the steps.' }])
session.append('todo/write', {
todos: [
{ content: 'read code', status: 'completed' },
{ content: 'write tests', status: 'in_progress' },
],
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
appendUser(session, 'Next question.')
},
})
await checkpoint('todo-plan-cleared', harness.terminal)
nowSpy.mockRestore()
await disposeSnapshot(harness)
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
@@ -325,8 +356,14 @@ describe('TUI terminal-state snapshots', () => {
harness.session.append('assistant/message', {
turn: 1,
step: 2,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
@@ -542,10 +579,10 @@ describe('TUI terminal-state snapshots', () => {
session.append('todo/write', {
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
@@ -656,23 +693,31 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
tools: ADVANCED_CARD_TOOLS,
beforeMount(session) {
const user = session.append('user/message', {
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
message: createToolResultMessage({
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
}),
}, { surfaceOp: 'append' })
replacementStart = user.seq
replacementEnd = result.seq
@@ -682,13 +727,13 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.session.append('user/message', {
harness.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
}],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, {
}), {
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
sourceEventSeqs: replacementSources,
})
@@ -775,10 +820,22 @@ describe('TUI terminal-state snapshots', () => {
meta: earlier,
events: [
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: createUserMessage({
content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'ready' }],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-pro' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },

View File

@@ -4,11 +4,15 @@ import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import {
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
MessageId,
createMessage,
freezeMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
@@ -236,10 +240,22 @@ describe('resume command and /resume', () => {
reason: TurnEndReason = { kind: 'completed' },
): SessionEvent[] => [
{ type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 1, time: time + 1, data: createUserMessage({
content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 4, time: time + 4, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{ provider, model: 'model-1' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
@@ -1094,7 +1110,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
}
const result = await setup({
beforeMount(session) {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
@@ -1103,7 +1119,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
round: 0,
change,
},
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
@@ -1198,22 +1214,42 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.status = 'running'
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
now = 8_000
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: ' ' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 2,
message: createUserMessage({
content: [{ type: 'text', text: 'steering note' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 2,
message: createUserMessage({
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, { surfaceOp: 'append' })
result.session.append('user/message', {
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<system-reminder>&#155;</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-control-context' },
}, { surfaceOp: 'append' })
result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' },
}), { surfaceOp: 'append' })
// A non-plugin injected source (goal) has no `plugin` field, so its context
// card label falls back to the source kind.
result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never,
}), { surfaceOp: 'append' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
@@ -1309,6 +1345,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')
@@ -1433,19 +1470,31 @@ describe('pi-tui chat lifecycle and transcript', () => {
const drainSteering = (text: string): void => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
result.ctx.emit('agent/inbox/dequeue', result.agent, {
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind: 'user' },
})
}), 'steering')
}
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
}
// A steering queue for a different agent never touches this status line.
const other = { ...result.agent, id: SessionId('other') } as Agent
result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } }, 'queued')
result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'elsewhere' }],
source: { kind: 'user' },
}), 'queued')
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -1483,8 +1532,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.output = ''
result.session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'continue: goal not reached' }],
source: { kind: 'plugin', plugin: 'hooks' },
message: createUserMessage({
content: [{ type: 'text', text: 'continue: goal not reached' }],
source: { kind: 'plugin', plugin: 'hooks' },
}),
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('1 queued')
@@ -1508,18 +1559,29 @@ describe('pi-tui chat lifecycle and transcript', () => {
submitSteering('fourth')
await tick()
expect(result.terminal.output).toContain('2 queued')
const discarded = result.agent.steeredIds.splice(0).map(id => ({
id, content: [{ type: 'text' as const, text: 'discarded' }], source: { kind: 'user' as const },
const discarded = result.agent.steeredIds.splice(0).map(id => freezeMessage({
id,
role: 'user' as const,
content: [{ type: 'text' as const, text: 'discarded' }],
source: { kind: 'user' as const },
}))
// Another agent's dequeue/discard, and ones naming no pending id, leave
// the badge alone.
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!)
result.ctx.emit('agent/inbox/dequeue', result.agent, {
id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
})
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!, 'steering')
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}), 'steering')
result.ctx.emit('agent/inbox/discard', other, discarded)
result.ctx.emit('agent/inbox/discard', result.agent, [
{ id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } },
freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}),
])
await tick()
expect(result.terminal.output).toContain('2 queued')
@@ -1840,8 +1902,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('tracks steering drains without a running status line', async () => {
const result = await setup()
const source = { kind: 'user' as const }
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source }, 'steering')
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' })
result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'early' }],
source,
}), 'steering')
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'early' }],
source,
}),
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).not.toContain('queued')
await dispose(result)
@@ -1896,7 +1969,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
])
result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c1' as never,
content: [{ type: 'text', text: 'command output' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.terminal.output = ''
result.session.append('step/end', { turn: 1, step: 1 })
@@ -1933,7 +2011,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
cwd: '/workspace',
config: { theme: { color: true } },
beforeMount(session) {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
@@ -1942,7 +2020,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
{} as never,
],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendAssistant(session, [
{ type: 'reasoning', text: 'styled reasoning' },
{ type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' },
@@ -2139,7 +2217,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 +2259,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 +2312,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')
@@ -2288,7 +2368,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source background' }],
source: { kind: 'user' },
}),
surfaceOp: 'append',
},
{
@@ -2335,7 +2418,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// the allow decision), not a separate pre-admission inject.
expect(result.agent.injected).toHaveLength(0)
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind).toBe('allow')
@@ -2345,7 +2428,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// The one-shot wrapper detached itself at admission: replaying the
// waterfall attaches nothing a second time.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2392,7 +2475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.agent.steered).toHaveLength(0)
expect(result.agent.injected).toHaveLength(0)
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
@@ -2422,13 +2505,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
await send()
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
// Each wrapper releases on its own allowed admission — matched by the
// message content it carries, not the returned id, which real send()
// assigns as a random UUID only after followup() returns. Running each
// prompt's admission waterfall detaches its wrapper.
for (const sent of result.agent.sent) {
// Each wrapper releases on its own identified message's allowed admission.
// Running each prompt's admission waterfall detaches its wrapper.
for (const sent of result.agent.sentMessages) {
await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent, { kind: 'user' },
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
}
@@ -2436,19 +2517,20 @@ describe('pi-tui chat lifecycle and transcript', () => {
// no armed listener, and an unrelated admission is untouched. The leak
// regression: a listener installed after its cleanup already ran would
// survive every future cleanup.
result.ctx.emit('agent/inbox/discard', result.agent, [{
id: AgentMessageId('stub'), content: result.agent.sent[0]!, source: { kind: 'user' },
}])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!])
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
// Replaying either sent prompt attaches nothing: the one-shot wrappers
// are gone, not merely spent.
for (const sent of result.agent.sent) {
for (const sent of result.agent.sentMessages) {
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent, { kind: 'user' },
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2466,17 +2548,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendUser(source, 'source background')
},
})
// Real send() publishes its snapshotted message, then an enqueue listener
// may synchronously cancel and discard it before followup() returns the
// already-assigned id. This stub reproduces that ordering.
// Real send() publishes its already identified snapshot, then an enqueue
// listener may synchronously cancel and discard it before followup()
// returns that id. This stub reproduces that ordering.
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
result.agent.followup = (input) => {
result.agent.sent.push(input.content)
const message = {
id: AgentMessageId('stub'),
result.agent.sentMessages.push(input)
const message = freezeMessage({
id: input.id,
role: 'user' as const,
content: structuredClone(input.content),
source: structuredClone(input.source),
}
})
result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued')
result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued')
result.ctx.emit('agent/inbox/discard', result.agent, [message])
@@ -2490,11 +2574,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
// The synchronous discard released the listeners even though followup()
// had not returned the id yet: replaying the prompt's admission attaches
// no stranded snapshot, and nothing leaks for the TUI lifetime.
// The synchronous discard released the listeners before followup()
// returned the existing id: replaying the prompt's admission attaches no
// stranded snapshot, and nothing leaks for the TUI lifetime.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2514,7 +2598,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A downstream admission hook blocks the prompt: the attached snapshot
// must be discarded with it, not stranded for the next prompt.
let blockPrompts = true
result.ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) =>
result.ctx.on('agent/prompt-submit', async (_agent, _message, _signal, next) =>
blockPrompts ? { kind: 'block' as const, reason: 'policy' } : next())
result.terminal.send('@blocked-source')
@@ -2525,7 +2609,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(blocked.kind).toBe('block')
@@ -2534,7 +2618,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.agent.injected).toHaveLength(0)
blockPrompts = false
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
@@ -2549,31 +2636,27 @@ describe('pi-tui chat lifecycle and transcript', () => {
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
// A different prompt passing the still-armed wrapper delegates untouched.
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'different prompt' }], { kind: 'user' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'different prompt' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
// A foreign agent's discard leaves the wrapper armed.
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
result.ctx.emit('agent/inbox/discard', foreign, [{
id: AgentMessageId('stub'),
content: result.agent.sent.at(-1)!,
result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!])
// An unrelated discard for this agent also leaves the wrapper armed.
result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({
content: [{ type: 'text', text: 'unrelated discard' }],
source: { kind: 'user' },
}])
result.ctx.emit('agent/inbox/discard', result.agent, [{
id: AgentMessageId('stub'),
content: result.agent.sent.at(-1)!,
source: { kind: 'user' },
}])
})])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
await tick()
// Idempotent: a repeat discard after cleanup is a no-op.
result.ctx.emit('agent/inbox/discard', result.agent, [{
id: AgentMessageId('stub'),
content: result.agent.sent.at(-1)!,
source: { kind: 'user' },
}])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent.at(-1)!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages.at(-1)!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
@@ -2728,7 +2811,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
]])
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
@@ -2817,47 +2900,49 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Session reference failed')
expect(result.terminal.output).toContain('keep @[')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hidden snapshot payload' }],
source: {
kind: 'session-reference',
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
} as never,
}, { surfaceOp: 'append' })
result.session.append('user/message', {
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'visible referenced question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('visible referenced question')
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
expect(result.terminal.output).not.toContain('hidden snapshot payload')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hidden steering context' }],
source: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
} as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'visible steering prompt' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'visible steering prompt' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('visible steering prompt')
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
expect(result.terminal.output).not.toContain('hidden steering context')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: {
kind: 'session-reference',
version: 1,
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
} as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
expect(result.terminal.output).not.toContain('secret full snapshot payload')
@@ -2869,15 +2954,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
]
for (const [source, text] of invalidCards) {
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: source as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'same-label snapshot' }],
source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · same')
await dispose(result)
@@ -3816,47 +3901,90 @@ describe('tool cards and surface replay', () => {
expect(result.terminal.output).toContain('call presenter boom')
expect(result.terminal.output).toContain('Symbol(input)')
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c1' as never,
content: [{ type: 'text', text: 'raw bash' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c2' as never,
content: [{ type: 'text', text: 'stopped' }],
isError: true,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c3' as never,
content: [{ type: 'text', text: 'done' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c4' as never,
content: [{ type: 'text', text: 'raw generic' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c5' as never,
content: [{ type: 'text', text: 'raw throwing' }],
isError: false,
}),
meta: { value: 1 },
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
{ type: 'future-result' } as never,
],
isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
{ type: 'future-result' } as never,
],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c8' as never,
content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c11' as never,
content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c13' as never,
content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c13' as never,
content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
callId: 'orphan' as never,
content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
isError: true,
message: createToolResultMessage({
callId: 'orphan' as never,
content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
isError: true,
}),
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
await tick()
@@ -3953,20 +4081,31 @@ describe('tool cards and surface replay', () => {
const assistant = result.session.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
result.session.append('tool/call', {
turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
})
const toolResult = result.session.append('tool/result', {
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'old-call' as never,
content: [{ type: 'text', text: 'old output' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary replacement' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
}), {
surfaceOp: { op: 'replace', start, end: toolResult.seq },
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
})
@@ -4287,7 +4426,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -4312,7 +4451,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -4347,14 +4486,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -4385,7 +4524,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -4429,7 +4568,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }

View File

@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -269,10 +269,10 @@ export class ApprovalService extends Service {
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject({
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
})
}))
})
}