Merge latest master into skill invocation controls

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/skill/tool-skill/src/index.ts
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-29 01:29:39 +08:00
665 changed files with 15945 additions and 6555 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: 59fc4ed46f047ee8f72aa237ec3a47b21091358f
README.zh.md: 596392a8eb11469a9f3afb34046c2c37521a4ed6
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06

View File

@@ -9,18 +9,18 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself.
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
## Personal config
@@ -41,7 +41,7 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
## Known Limitations and Deferred Work
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping.
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.

View File

@@ -9,18 +9,18 @@
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目(即导入失败的插件模块),则抛出异常 |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection。
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
此包不包含 loader 钩子,也不提供开发模式接口:`dsh-scripts` launcher([`sdk/scripts`](../../sdk/scripts/README.md),共享项目模型见 [`sdk/helper`](../../sdk/helper/README.md))持有进程启动、tsx 注册和本地插件源代码解析,并在自身的启动序列中使用这些 helper。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
## 个人配置
@@ -41,7 +41,7 @@
## 已知限制与延期工作
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或使用 tsx 路径映射。
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。

View File

@@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
}
/**
* After the tree settles, reject entries with no fiber, which indicates a
* swallowed module-import failure. Disabled entries are the only valid
* After the tree settles, reject entries with no fiber and name every plugin
* whose module failed to resolve. Disabled entries are the only valid
* fiber-less state.
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
@@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
}
}

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: 126f39f37ed86d049da53facea5379841a314ad8
README.zh.md: 1dc7b925a0cd239f003e209f73841afd049d4dc9
README.md: d5c559fa101791cbf17261888314c131388a4318
README.zh.md: 41005e13f036fc3a5a7fbbb447bcc5be9818b3eb

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

@@ -101,7 +101,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 {
@@ -284,7 +283,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
@@ -659,7 +658,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'))
@@ -675,7 +674,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()
@@ -692,7 +691,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))
@@ -700,13 +700,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()
@@ -763,6 +767,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) {
@@ -1123,7 +1128,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
@@ -1132,43 +1137,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
@@ -1179,15 +1178,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()
@@ -1395,7 +1392,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

@@ -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' } })
@@ -1434,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')
@@ -1484,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')
@@ -1509,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')
@@ -1841,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)
@@ -1897,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 })
@@ -1934,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: '{}' },
@@ -1943,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```' },
@@ -2291,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',
},
{
@@ -2338,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')
@@ -2348,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()
@@ -2395,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)
@@ -2425,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 }),
)
}
@@ -2439,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()
@@ -2469,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])
@@ -2493,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()
@@ -2517,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')
@@ -2528,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')
@@ -2537,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()
@@ -2552,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()
@@ -2731,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)
@@ -2820,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')
@@ -2872,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)
@@ -3855,47 +3937,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()
@@ -3992,20 +4117,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],
})
@@ -4326,7 +4462,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() })
@@ -4351,7 +4487,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).
@@ -4386,14 +4522,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()
@@ -4424,7 +4560,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)
@@ -4468,7 +4604,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'
@@ -60,11 +60,15 @@ declare module '@deepseek-ai/dsh-session' {
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy});
* who asked for it is derivable from position (an event after the log's
* last `request/header` was a runtime switch by the user).
* event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
*/
'approval/policy': { policy: ApprovalPolicy }
'approval/policy': {
policy: ApprovalPolicy
/** Marks an override seeded into a child at delegation. */
source?: 'delegation'
}
}
}
@@ -250,11 +254,13 @@ export class ApprovalService extends Service {
const session = agent.session
const events = session.events
let overrideIndex = -1
let overrideSource: 'delegation' | undefined
let headerIndex = -1
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
overrideSource = event.data.source
} else if (headerIndex < 0 && event.type === 'request/header') {
headerIndex = index
}
@@ -268,11 +274,13 @@ export class ApprovalService extends Service {
// Cold start (nothing ever told) narrates nothing — the section about
// 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({
const cause = overrideSource === 'delegation'
? 'inherited from the delegating session'
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
})
}))
})
}
@@ -323,7 +331,16 @@ export class ApprovalService extends Service {
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
}
/**
* Read the session override without applying the configured default.
* @param session - session whose log supplies the override.
* @returns the last logged policy, or `undefined` without one.
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
return effectiveApprovalPolicy(session.events)
}
/**

View File

@@ -456,7 +456,9 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService, { policy: 'never' })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const { agent, session } = sessionAgent('sess-gate-3')
expect(ctx.approval.overrideOf(session)).toBeUndefined()
setApprovalPolicy(session, 'ask')
expect(ctx.approval.overrideOf(session)).toBe('ask')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
setApprovalPolicy(session, 'never')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
@@ -507,6 +509,18 @@ describe('approval policy (the approval/policy fold)', () => {
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
it('narrates a config default drift from the logged ask marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })