Merge commit 'refs/codex/unblock/master-current' into HEAD

# Conflicts:
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/core/agent/src/index.ts
#	packages/support/invariants/tests/invariants.spec.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 20:20:53 +08:00
631 files changed
+21514 -3341

No files matched your search

+6
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -30,6 +35,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
+8 -41
View File
@@ -44,10 +44,15 @@ import {
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
installAgentLlmTarget,
type Agent,
type AgentLlmTarget as LlmTarget,
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
@@ -258,19 +263,6 @@ export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
})
/** Provider/model pair selected for one ACP session. */
interface LlmTarget {
provider: string
model: string
}
/** Mutable target shared by one agent's scoped assembly and request listeners. */
interface LlmTargetRef {
current: LlmTarget | undefined
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
assembled: LlmTarget | undefined
}
/** One resolved ACP model selector plus its opaque value lookup. */
interface ModelDirectory {
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
@@ -338,32 +330,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
// Capture once at assembly entry and apply the same pair after downstream
// prompt listeners. A selector change during async assembly therefore takes
// effect on the following step instead of splitting {{model}} from routing.
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, _signal, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
})
installAgentLlmTarget(agentCtx, target)
}
/** Opaque ACP value preserving both routing dimensions. */
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-acp`.
* @module @deepseek-ai/dsh-acp/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
/** Cordis companion plugin name. */
export const name = 'acp-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+12 -2
View File
@@ -8,7 +8,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
@@ -25,6 +28,13 @@ class SandboxedLocalExecutor extends LocalBashExecutor {
}
}
async function mountInvariants(ctx: BridgeHarness['ctx']): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
function permissionOption(currentValue: string): object {
return {
id: 'permission',
@@ -76,7 +86,7 @@ describe('acp bridge — session config options', () => {
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
// Make an out-of-turn switch fail in this suite.
await harness.ctx.plugin(Invariants)
await mountInvariants(harness.ctx)
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await harness.ctx.plugin(ApprovalService)
await harness.ctx.plugin(PermissionService)
+3
View File
@@ -52,6 +52,9 @@
},
{
"path": "../../bash/bash"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -24,11 +29,13 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`.
* @module @deepseek-ai/dsh-app-boot/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot'
/** Cordis companion plugin name. */
export const name = 'app-boot-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+3
View File
@@ -16,6 +16,9 @@
},
{
"path": "../../../vendor/include"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,11 +28,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
* @module @deepseek-ai/dsh-commands/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
/** Cordis companion plugin name. */
export const name = 'commands-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: registry notifications intentionally hide mutation details and contain
* observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+3
View File
@@ -19,6 +19,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -26,6 +31,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-deepseek": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
@@ -37,6 +43,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc`.
* @module @deepseek-ai/dsh-jsonrpc/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc'
/** Cordis companion plugin name. */
export const name = 'jsonrpc-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+3
View File
@@ -28,6 +28,9 @@
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -34,6 +40,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+39
View File
@@ -0,0 +1,39 @@
/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-permission'
/** Cordis companion plugin name. */
export const name = 'permission-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate the package-owned event shape and ignore unrelated events. */
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
}
}
/** Install validation that loaded and newly appended preset events remain resolvable. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(ctx, event, fail)
}
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const event = (args as [Session, SessionEvent])[1]
validateEvent(ctx, event, fail)
}, { global: true })
}, { inject: ['permission', 'sessions'] })
/**
* Register the permission invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { Context, Service } from 'cordis'
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
class PermissionProbe extends Service {
readonly names = ['safe', 'trusted']
constructor(ctx: Context) {
super(ctx, 'permission')
}
}
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(PermissionProbe)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(PermissionInvariant)
return ctx
}
function presetEvent(preset: string): SessionEvent {
return { type: 'permission/preset', seq: 0, time: 0, data: { preset } }
}
describe('permission invariants', () => {
it('accepts configured preset events and ignores other session data', async () => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, {
type: 'turn/end', seq: 0, time: 0, data: {},
} as SessionEvent) }).not.toThrow()
expect(() => { ctx.emit('tools/change') }).not.toThrow()
})
it('rejects a durable preset that the active table cannot resolve', async () => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) })
.toThrow(/unknown preset "missing"/)
})
it('rejects an unknown preset already present on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(PermissionProbe)
ctx.sessions.create().append('permission/preset', { preset: 'missing' })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/)
})
})
@@ -12,7 +12,12 @@ async function mounted(options: {
approvalDefault?: ApprovalPolicy | undefined
} = {}): Promise<Context> {
const ctx = new Context()
ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' })
ctx.provide('bash', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
await ctx.plugin(PermissionService, options.config ?? {})
return ctx
+3
View File
@@ -31,6 +31,9 @@
},
{
"path": "../user-approval"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,12 +28,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-ask-user`.
* @module @deepseek-ai/dsh-tool-ask-user/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user'
/** Cordis companion plugin name. */
export const name = 'tool-ask-user-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
* relations are owned by the capability seam it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+3
View File
@@ -31,6 +31,9 @@
},
{
"path": "../user-interaction"
},
{
"path": "../../support/invariants"
}
]
}
+28 -9
View File
@@ -6,13 +6,15 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
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 as keyboard-driven overlays. 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. 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 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. 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.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; 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.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
## Config
@@ -21,10 +23,13 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
| `welcome` | `ready.` | Header subtitle |
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
| `showReasoning` | `true` | Render reasoning blocks |
| `maxToolOutputLines` | `12` | Collapsed tool-card output limit |
| `maxQuestionOptions` | `8` | Visible options in a question overlay |
| `questionDialogWidth` | `72` | Question-overlay width in columns |
| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows |
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
| `maxQuestionOptions` | `8` | Visible options in a question panel |
| `maxModelOptions` | `8` | Visible models in the model selector |
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `72` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
| `title` | `DeepSeek Harness` | Terminal window title |
@@ -36,14 +41,14 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
welcome: 'Coding agent ready.'
sessionId: main-session-123
showReasoning: true
maxToolOutputLines: 12
maxToolOutputLines: 6
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
## Model Experience
@@ -61,6 +66,20 @@ Submitted text is retained under the agent loop's normal session-history and com
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Session model selection
#### What the model sees
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing.
#### Token effect
The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model.
#### KV Cache effect
Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed.
### Interactive user-question answers
#### What the model sees
+10
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -25,9 +30,12 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -41,10 +49,12 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+405 -60
View File
@@ -13,12 +13,12 @@ import {
Editor,
Input,
Key,
Loader,
Markdown,
Spacer,
Text,
TUI,
ProcessTerminal,
SelectList,
matchesKey,
truncateToWidth,
visibleWidth,
@@ -33,11 +33,23 @@ import {
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import {
installAgentLlmTarget,
type Agent,
type AgentLlmTarget,
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 {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmModelInfo,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import type {
@@ -56,20 +68,26 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
/** Render model reasoning blocks. */
showReasoning?: boolean
/** Maximum tool-output lines shown before the card is collapsed. */
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum options visible at once in a user-question dialog. */
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** User-question dialog width in terminal columns. */
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question dialog maximum height in terminal rows. */
/** User-question panel maximum height in terminal rows. */
questionDialogMaxHeight?: number
/** Model-selector width in terminal columns. */
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
showHardwareCursor?: boolean
/** Apply the built-in ANSI color palette. */
@@ -79,10 +97,13 @@ export interface TuiConfig {
}
const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(72)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const showHardwareCursorSchema = z.boolean().default(false)
const colorSchema = z.boolean().default(true)
const titleSchema = z.string().default('DeepSeek Harness')
@@ -92,8 +113,11 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
@@ -113,8 +137,11 @@ export const Config: z<Config> = z.object({
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
@@ -125,8 +152,11 @@ export interface ResolvedTuiConfig {
showReasoning: boolean
maxToolOutputLines: number
maxQuestionOptions: number
maxModelOptions: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
showHardwareCursor: boolean
color: boolean
title: string
@@ -138,6 +168,8 @@ export interface TuiRuntime {
terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
now?(): number
}
/**
@@ -149,10 +181,13 @@ export interface TuiRuntime {
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
return {
showReasoning: config?.showReasoning ?? true,
maxToolOutputLines: config?.maxToolOutputLines ?? 12,
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 72,
maxModelOptions: config?.maxModelOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
showHardwareCursor: config?.showHardwareCursor ?? false,
color: config?.color ?? true,
title: config?.title ?? 'DeepSeek Harness',
@@ -253,6 +288,13 @@ function selectTheme(palette: Palette): SelectListTheme {
}
}
function dialogSelectTheme(palette: Palette): SelectListTheme {
return {
...selectTheme(palette),
selectedText: text => palette.selected(palette.accent(text)),
}
}
function contentText(content: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of content) {
@@ -284,11 +326,52 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'
.join('\n\n')
}
interface ModelChoice extends AgentLlmTarget {
modelName: string
description?: string
}
function targetLabel(target: AgentLlmTarget): string {
return `${target.provider}/${target.model}`
}
function initialTarget(agent: Agent): AgentLlmTarget | undefined {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) return { provider: logged.provider, model: logged.model }
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
return { provider: agent.options.provider, model: agent.options.model }
}
async function readModelChoices(
ctx: Context,
current: AgentLlmTarget | undefined,
): Promise<ModelChoice[]> {
const providers = ctx.llm.listProviders()
const groups = await Promise.all(providers.map(async (provider) => {
const advertised = await ctx.llm.listModels(provider.id)
const models: LlmModelInfo[] = [...advertised]
if (
current?.provider === provider.id
&& !models.some(model => model.id === current.model)
) {
models.push({ provider: provider.id, id: current.model, name: current.model })
}
return models.map((model): ModelChoice => ({
provider: provider.id,
model: model.id,
modelName: model.name,
...model.description === undefined ? {} : { description: model.description },
}))
}))
return groups.flat()
}
class HeaderComponent implements Component {
constructor(
private readonly agent: Agent,
private readonly welcome: string,
private readonly palette: Palette,
private readonly currentModel: () => string | undefined,
) {}
invalidate(): void {}
@@ -296,7 +379,7 @@ class HeaderComponent implements Component {
render(width: number): string[] {
const usable = Math.max(1, width - 4)
const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
const model = displayText(this.agent.options.model ?? 'model unset')
const model = displayText(this.currentModel() ?? 'model unset')
const detail = `${model}${displayText(this.agent.session.id)}`
const top = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
const bottom = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
@@ -508,9 +591,15 @@ class ToolCardComponent implements Component {
const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
const body = this.renderBody()
const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
const headLines = Math.ceil(this.maxOutputLines / 2)
const tailLines = this.maxOutputLines - headLines
const visibleBody = this.expanded || body.length <= this.maxOutputLines
? body
: [...body.slice(0, this.maxOutputLines), this.palette.dim(`${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)]
: [
...body.slice(0, headLines),
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
...body.slice(body.length - tailLines),
]
const barFn = this.result === undefined
? this.palette.warning
: isError ? this.palette.error : this.palette.success
@@ -647,19 +736,41 @@ class FooterComponent implements Component {
private readonly toolsExpanded: () => boolean,
private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number },
private readonly currentModel: () => string | undefined,
private readonly contextPercent: () => number | undefined,
private readonly runningSeconds: () => number,
) {}
invalidate(): void {}
render(width: number): string[] {
if (this.agent.status === 'running') {
const interrupt = this.palette.dim('esc interrupt')
const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1)
const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt)))
return [`${activity}${gap}${interrupt}`]
}
const { input, output } = this.tokens()
const left = `${formatCwd(this.agent.session.header.cwd)} ${formatTokens(input)}${formatTokens(output)}`
const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}`
const leftStyled = this.palette.dim(left)
const available = Math.max(0, width - visibleWidth(left) - 2)
const rightClipped = truncateToWidth(right, available, '')
const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped)))
return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')]
const counters = `${formatTokens(input)}${formatTokens(output)}`
const model = displayText(this.currentModel() ?? 'model unset')
const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
const contextPercent = this.contextPercent()
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
const compactRight = `${context} ${modelState}`
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
const compact = truncateToWidth(compactRight, width, '')
return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
}
const rightAvailable = width - visibleWidth(counters) - 1
const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight
const rightClipped = truncateToWidth(right, rightAvailable, '')
const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3)
const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '')
const left = [cwd, counters].filter(Boolean).join(' ')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped)))
return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`]
}
}
@@ -668,6 +779,76 @@ interface QuestionSelection {
custom?: string
}
function renderDialog(
title: string,
body: readonly string[],
width: number,
palette: Palette,
): string[] {
const innerWidth = Math.max(1, width - 4)
const topLabel = ` ${displayText(title)} `
const top = `${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}`
const lines: string[] = [palette.accent(top)]
for (const line of body) {
const clipped = truncateToWidth(line, innerWidth, '')
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
}
lines.push(palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`))
return lines
}
class ModelDialog implements Component {
private readonly list: SelectList
constructor(
choices: readonly ModelChoice[],
current: AgentLlmTarget | undefined,
maxVisible: number,
private readonly palette: Palette,
done: (choice: ModelChoice) => void,
cancel: () => void,
) {
this.list = new SelectList(choices.map(choice => ({
value: targetLabel(choice),
label: displayText(targetLabel(choice)),
description: [
displayText(choice.modelName),
...choice.description === undefined ? [] : [displayText(choice.description)],
...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
].join(' — '),
})), maxVisible, dialogSelectTheme(palette))
const currentIndex = current === undefined
? 0
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
this.list.setSelectedIndex(currentIndex)
this.list.onSelect = (item) => {
const selected = choices.find(choice => targetLabel(choice) === item.value)
/* v8 ignore next -- SelectList only returns values built from `choices`. */
if (selected === undefined) return
done(selected)
}
this.list.onCancel = cancel
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
this.list.handleInput(data)
this.invalidate()
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
return renderDialog('Select model', [
...this.list.render(innerWidth),
'',
this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
], width, this.palette)
}
}
class QuestionDialog implements Component, Focusable {
private selectedIndex = 0
private selected = new Set<number>()
@@ -679,6 +860,9 @@ class QuestionDialog implements Component, Focusable {
constructor(
private readonly question: AskUserQuestionItem,
private readonly position: number,
private readonly total: number,
private readonly unanswered: number,
private readonly maxVisible: number,
private readonly palette: Palette,
private readonly done: (selection: QuestionSelection) => void,
@@ -719,11 +903,11 @@ class QuestionDialog implements Component, Focusable {
} else if (matchesKey(data, Key.enter)) {
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
if (indices.length === 0) {
this.error = 'Select at least one option, or press C for a custom answer.'
this.error = 'Select at least one option, or press Tab for a custom answer.'
return
}
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
} else if (data.toLowerCase() === 'c') {
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.error = ''
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
@@ -743,16 +927,13 @@ class QuestionDialog implements Component, Focusable {
render(width: number): string[] {
this.input.focused = this.focused
const innerWidth = Math.max(1, width - 4)
const title = displayText(this.question.header ?? 'Question')
const topLabel = ` ${title} `
const top = `${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}`
const lines: string[] = [this.palette.accent(top)]
const push = (line: string): void => {
const clipped = truncateToWidth(line, innerWidth, '')
lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`)
}
for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line)
push('')
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
const lines = [
this.palette.muted(header),
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
'',
]
const push = (line: string): void => { lines.push(line) }
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
@@ -763,27 +944,45 @@ class QuestionDialog implements Component, Focusable {
options.length - this.maxVisible,
))
const end = Math.min(options.length, start + this.maxVisible)
const optionRows = options.slice(start, end).map((option, offset) => {
const index = start + offset
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
return `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
})
const descriptionColumn = Math.min(
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
Math.max(1, Math.floor(innerWidth * 0.55)),
)
for (let index = start; index < end; index += 1) {
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
const cursor = index === this.selectedIndex ? this.palette.accent('') : ' '
const mark = this.question.multiSelect
? this.selected.has(index) ? this.palette.success('[x]') : '[ ]'
: index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○')
const description = option.description
? this.palette.muted(`${displayText(option.description)}`)
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
const line = `${cursor} ${mark} ${displayText(option.label)}${description}`
push(index === this.selectedIndex ? this.palette.selected(line) : line)
const left = `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
const leftStyled = index === this.selectedIndex
? this.palette.bold(this.palette.accent(left))
: left
const description = option.description === undefined
? ''
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
push(`${leftStyled}${description}`)
}
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
push(this.palette.dim(this.question.multiSelect
? '↓ navigate • Space toggle • Enter submit • C custom • Esc cancel'
: '↑↓ navigate • Enter select • C custom • Esc cancel'))
const hint = this.palette.dim(this.question.multiSelect
? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt'
: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt')
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
}
if (this.error) push(this.palette.error(this.error))
lines.push(this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`))
return lines
if (this.error) {
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
}
return ['', ...lines, ''].map((line) => {
const clipped = truncateToWidth(line, innerWidth, '')
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
})
}
}
@@ -839,7 +1038,6 @@ export function createTuiChat(
const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
const chat = new Container()
const todoContainer = new Container()
const statusContainer = new Container()
const editor = new Editor(ui, {
borderColor: palette.dim,
selectList: selectTheme(palette),
@@ -848,7 +1046,8 @@ export function createTuiChat(
let showReasoning = resolved.showReasoning
let toolsExpanded = false
let streaming: StreamingAssistantComponent | undefined
let statusLoader: Loader | undefined
let runningStartedAt: number | undefined
let statusTicker: ReturnType<typeof setInterval> | undefined
let disposed = false
let shuttingDown: Promise<void> | undefined
const tokens = sessionTokens(agent.session)
@@ -858,13 +1057,32 @@ export function createTuiChat(
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: OverlayHandle | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
let contextResolution: Promise<
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
| { readonly kind: 'error'; readonly error: unknown }
> | undefined
let modelCommands = Promise.resolve()
const now = (): number => runtime.now?.() ?? Date.now()
const welcome = config.welcome ?? 'ready.'
const header = new HeaderComponent(agent, welcome, palette)
const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens)
const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model)
const footer = new FooterComponent(
agent,
palette,
() => toolsExpanded,
() => showReasoning,
() => tokens,
() => target.current?.model,
() => contextWindow === undefined
? undefined
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
() => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
)
ui.addChild(header)
ui.addChild(chat)
ui.addChild(statusContainer)
todoContainer.addChild(todo)
ui.addChild(todoContainer)
ui.addChild(editor)
@@ -884,10 +1102,120 @@ export function createTuiChat(
requestRender()
}
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
const resolution = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
(error: unknown) => ({ kind: 'error', error } as const),
)
contextResolution = resolution
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
contextWindow = result.contextWindow
requestRender()
})
}
resolveContextWindow(target.current)
const selectModel = (selected: ModelChoice): void => {
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
appendNotice(`Model is already ${targetLabel(selected)}.`)
return
}
target.current = { provider: selected.provider, model: selected.model }
resolveContextWindow(target.current)
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
}
const showModelSelector = (choices: readonly ModelChoice[]): void => {
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
if (choices.length === 0) {
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
return
}
modelOverlay?.hide()
modelOverlay = undefined
const close = (): void => {
modelOverlay?.hide()
modelOverlay = undefined
requestRender()
}
const dialog = new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
close()
selectModel(selected)
},
close,
)
modelOverlay = ui.showOverlay(dialog, {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
})
requestRender()
}
const handleModelCommand = async (raw: string): Promise<void> => {
const choices = await readModelChoices(ctx, target.current)
if (disposed) return
const argument = raw.trim()
if (argument === '') {
showModelSelector(choices)
return
}
const parts = argument.split(/\s+/u)
if (parts.length > 2) {
appendNotice('Usage: /model [provider/]model', 'warning')
return
}
let matches: ModelChoice[]
if (parts.length === 2) {
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
} else {
const value = argument
const qualified = choices.filter(choice => targetLabel(choice) === value)
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
}
if (matches.length === 0) {
appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
return
}
if (matches.length > 1) {
appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
return
}
const selected = matches[0]
/* v8 ignore next -- a non-empty matches array always has index zero. */
if (selected === undefined) return
selectModel(selected)
}
const queueModelCommand = (raw: string): void => {
modelCommands = modelCommands.then(async () => {
await handleModelCommand(raw)
}).catch((error: unknown) => {
if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
})
}
const clearStatus = (): void => {
statusLoader?.stop()
statusLoader = undefined
statusContainer.clear()
if (statusTicker !== undefined) clearInterval(statusTicker)
statusTicker = undefined
runningStartedAt = undefined
runtime.terminal.setProgress(false)
}
@@ -895,8 +1223,9 @@ export function createTuiChat(
clearStatus()
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
if (status === 'running') {
statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels')
statusContainer.addChild(statusLoader)
runningStartedAt = now()
statusTicker = setInterval(requestRender, 1_000)
statusTicker.unref()
runtime.terminal.setProgress(true)
}
requestRender()
@@ -1072,6 +1401,9 @@ export function createTuiChat(
}
const dialog = new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
@@ -1090,8 +1422,8 @@ export function createTuiChat(
pending.overlay = ui.showOverlay(dialog, {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'center',
margin: 1,
anchor: 'bottom-left',
margin: { bottom: 1 },
})
requestRender()
}
@@ -1130,7 +1462,10 @@ export function createTuiChat(
const shutdown = (exitProcess: boolean): Promise<void> => {
shuttingDown ??= (async () => {
disposed = true
contextResolution = undefined
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
if (activeQuestion !== undefined) {
@@ -1184,7 +1519,7 @@ export function createTuiChat(
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
chat.addChild(new Text([
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
'',
...commandLines,
@@ -1213,6 +1548,15 @@ export function createTuiChat(
description: 'Show keyboard shortcuts and commands',
handler: () => { showHelp(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'model',
description: 'Show or switch this session\'s model',
input: { hint: '[[provider/]model]' },
handler: ({ rawInput }) => {
queueModelCommand(rawInput)
return { kind: 'success' }
},
})
commandCtx.commands.register({
name: 'clear',
description: 'Clear the transcript view (session history is unchanged)',
@@ -1288,7 +1632,7 @@ export function createTuiChat(
}
const removeInputListener = ui.addInputListener((data) => {
if (activeQuestion !== undefined) return undefined
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
if (matchesKey(data, Key.ctrl('o'))) {
toggleTools()
return { consume: true }
@@ -1358,6 +1702,7 @@ export function createTuiChat(
disposeStatus()
disposeError()
disposeAgent()
disposeTargetListeners()
}
rebuildTranscript(true)
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
* @module @deepseek-ai/dsh-tui/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
/** Cordis companion plugin name. */
export const name = 'tui-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+48 -7
View File
@@ -1,9 +1,10 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentCancelCause, type AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type AgentCancelCause, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config } from '../src/index.ts'
@@ -22,6 +23,16 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
now?: () => number
catalog?: {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -50,6 +61,31 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('llm', {
listProviders() {
return catalog.providers.map(provider => ({ ...provider }))
},
listModels(provider: string) {
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
},
} as never)
ctx.provide('tokenMeter', {
measure() {
return { totalTokens: options.contextTokens ?? 0 }
},
} as never)
if (options.configureContext === undefined) {
const tools = options.tools ?? {}
ctx.provide('tools', {
@@ -60,18 +96,24 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
} else {
await options.configureContext(ctx)
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
sessionId,
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
)
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
options: { model: 'deepseek-v4-flash' },
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
session,
status: options.status ?? 'idle',
ctx,
@@ -97,7 +139,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit })
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
return { ctx, session, agent, terminal, exit, controller }
}
@@ -122,11 +164,10 @@ export function appendAssistant(
session: Session,
content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number },
position: { turn: number; step: number } = { turn: 1, step: 0 },
position: { turn: number; step: number } = { turn: 1, step: 1 },
): void {
session.append('assistant/message', {
turn: position.turn,
step: position.step,
...position,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
...usage === undefined ? {} : { usage },
+9 -1
View File
@@ -12,7 +12,15 @@ describe('dsh-tui plugin export shape', () => {
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
expect(unwrapped).toBe(tui)
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
expect(unwrapped.inject).toEqual([
'agents',
'commands',
'userInteraction',
'tools',
'llm',
'systemPrompt',
'tokenMeter',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
@@ -33,11 +33,12 @@ buffer
9| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
10| "▌ packages/ui/tui 100% "
10| "▌ … +4 lines (Ctrl+O to expand) "
style 0-0 fg=green
11| "▌ … 4 more lines (Ctrl+O to expand) "
style 2-30 dim
11| "▌ [exit 0] "
style 0-0 fg=green
style 2-34 dim
style 2-9 dim
12| "▌ "
style 0-0 fg=green
13| <blank>
@@ -53,12 +54,12 @@ buffer
17| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
18| "▌ - keep "
18| "▌ … +5 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-7 fg=red
19| "▌ … 5 more lines (Ctrl+O to expand) "
style 2-30 dim
19| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-34 dim
style 2-35 fg=green
20| "▌ "
style 0-0 fg=green
21| <blank>
@@ -102,6 +103,6 @@ buffer
style 1-1 inverse
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 67-99 dim
style 42-99 dim
@@ -122,6 +122,6 @@ buffer
style 1-1 inverse
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded"
49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 66-99 dim
style 41-99 dim
@@ -46,7 +46,7 @@ buffer
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
18-35| <blank>
@@ -1,5 +1,5 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
lifecycle started=1 stopped=0 progress=active
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
viewport
@@ -41,12 +41,12 @@ viewport
15| " Streaming visible state… "
style 11-23 bold
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
style 0-95 fg=bright-blue
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
style 0-95 fg=bright-blue
19| "◒ Working · 0s esc interrupt"
style 0-13 fg=bright-blue
style 83-95 dim
20-35| <blank>
@@ -53,7 +53,7 @@ buffer
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
21-35| <blank>
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=29 bufferRow=29
cursor visible column=0 viewportRow=30 bufferRow=30
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ buffer
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>
@@ -33,8 +33,9 @@ buffer
style 0-0 fg=yellow
10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
style 0-0 fg=yellow
11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), "
11| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=yellow
style 2-30 dim
12| "▌ ]) "
style 0-0 fg=yellow
13| "▌ phase('Verify') "
@@ -49,7 +50,7 @@ buffer
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
20-35| <blank>
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=25 bufferRow=25
cursor hidden column=1 viewportRow=26 bufferRow=26
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ buffer
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>
@@ -0,0 +1,52 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=31 bufferRow=31
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
9-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
style 10-81 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
style 10-10 fg=bright-blue
style 12-72 fg=bright-blue inverse
style 81-81 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
style 10-10 fg=bright-blue
style 38-60 fg=bright-black
style 81-81 fg=bright-blue
16| " │ │ "
style 10-10 fg=bright-blue
style 81-81 fg=bright-blue
17| " │ ↑/↓ navigate • Enter select • Esc cancel │ "
style 10-10 fg=bright-blue
style 12-51 dim
style 81-81 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────╯ "
style 10-81 fg=bright-blue
19-31| <blank>
@@ -0,0 +1,35 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=8 bufferRow=8
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-pro • main-session │"
style 0-0 fg=bright-blue
style 2-33 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| <blank>
6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 1-64 fg=bright-black
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| " "
style 1-1 inverse
9| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)"
style 0-24 dim
style 36-91 dim
11-31| <blank>
@@ -1,7 +1,7 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=13 bufferRow=13
cursor hidden column=56 viewportRow=17 bufferRow=17
viewport
0| "╭──────────────────────────────────────────────────────╮"
style 0-55 fg=bright-blue
@@ -18,52 +18,30 @@ viewport
style 0-0 fg=bright-blue
style 2-35 dim
style 55-55 fg=bright-blue
4| "╰───╭ Coverage ───────────────────────────────────────╯"
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
5| "────│ Which advanced TUI states belong in the │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-50 bold
style 51-51 fg=bright-blue bold
style 52-55 dim
6| " │ required matrix? │ "
style 1-1 inverse
style 4-4 fg=bright-blue
style 6-21 bold
style 51-51 fg=bright-blue
7| "────│ │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 51-51 fg=bright-blue
style 52-55 dim
8| "/wor│ [ ] Code Mode — run_code programs and capt │:com"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-6 fg=bright-blue inverse
style 7-20 inverse
style 21-49 fg=bright-black inverse
style 51-51 fg=bright-blue
style 52-55 dim
9| " │ [ ] Workflows — phases and parallel agents │ "
style 4-4 fg=bright-blue
style 21-49 fg=bright-black
style 51-51 fg=bright-blue
10| " │ [ ] Cordis tools — inspect, mount, and unm │ "
style 4-4 fg=bright-blue
style 24-49 fg=bright-black
style 51-51 fg=bright-blue
11| " │ 1/4 │ "
style 4-4 fg=bright-blue
style 6-8 dim
style 51-51 fg=bright-blue
12| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
style 4-4 fg=bright-blue
style 6-49 dim
style 51-51 fg=bright-blue
13| " │ Select at least one option, or press C for a │ "
style 4-4 fg=bright-blue
style 6-49 fg=red
style 51-51 fg=bright-blue
14| " ╰──────────────────────────────────────────────╯ "
style 4-51 fg=bright-blue
15-19| <blank>
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black
7| " Which advanced TUI states belong in the required "
8| " matrix? "
9| " "
10| " 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-blue bold
style 25-53 fg=bright-black
11| " 2. [ ] Workflows phases and parallel agents "
style 25-50 fg=bright-black
12| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 fg=bright-black
13| " 1/4 "
style 2-4 dim
14| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
15| " Enter submit • Esc interrupt "
style 2-29 dim
16| " Select at least one option, or press Tab for a "
style 2-55 fg=red
17| " custom answer. "
style 2-15 fg=red
18| " "
19| <blank>
@@ -20,48 +20,28 @@ viewport
style 55-55 fg=bright-blue
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
5| "────╭ Coverage ────────────────────────────────────────"
style 0-3 dim
style 4-51 fg=bright-blue
style 52-55 dim
6| " │ Which advanced TUI states belong in the │ "
5| "────────────────────────────────────────────────────────"
style 0-55 dim
6| " "
style 1-1 inverse
style 4-4 fg=bright-blue
style 6-50 bold
style 51-51 fg=bright-blue bold
7| "────│ required matrix? │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-21 bold
style 51-51 fg=bright-blue
style 52-55 dim
8| "/wor│ │:com"
style 0-3 dim
style 4-4 fg=bright-blue
style 51-51 fg=bright-blue
style 52-55 dim
9| " │ [ ] Code Mode — run_code programs and capt │ "
style 4-4 fg=bright-blue
style 6-6 fg=bright-blue inverse
style 7-20 inverse
style 21-49 fg=bright-black inverse
style 51-51 fg=bright-blue
10| " │ [ ] Workflows — phases and parallel agents │ "
style 4-4 fg=bright-blue
style 21-49 fg=bright-black
style 51-51 fg=bright-blue
11| " │ [ ] Cordis tools — inspect, mount, and unm │ "
style 4-4 fg=bright-blue
style 24-49 fg=bright-black
style 51-51 fg=bright-blue
12| " │ 1/4 │ "
style 4-4 fg=bright-blue
style 6-8 dim
style 51-51 fg=bright-blue
13| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
style 4-4 fg=bright-blue
style 6-49 dim
style 51-51 fg=bright-blue
14| " ╰──────────────────────────────────────────────╯ "
style 4-51 fg=bright-blue
15-19| <blank>
7| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black
9| " Which advanced TUI states belong in the required "
10| " matrix? "
11| " "
12| " 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-blue bold
style 25-53 fg=bright-black
13| " 2. [ ] Workflows phases and parallel agents "
style 25-50 fg=bright-black
14| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 fg=bright-black
15| " 1/4 "
style 2-4 dim
16| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
17| " Enter submit • Esc interrupt "
style 2-29 dim
18| " "
19| <blank>
@@ -42,7 +42,7 @@ buffer
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
18-35| <blank>
@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
16-35| <blank>
@@ -43,7 +43,7 @@ buffer
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
19-35| <blank>
@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
16-35| <blank>
@@ -35,7 +35,6 @@ buffer
style 1-1 inverse
12| "────────────────────────────────────────────"
style 0-43 dim
13| "/workspace/project ↑0 ↓0 idle reasoning:o"
style 0-24 dim
style 27-43 dim
13| " 0% context deepseek-v4-flash(reasoning:on)"
style 1-43 dim
14-17| <blank>
@@ -31,7 +31,7 @@ buffer
style 1-1 inverse
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 71-103 dim
style 46-103 dim
12-29| <blank>
@@ -45,8 +45,9 @@ buffer
style 2-19 dim
15| "▌ packages/ui/tui 100% "
style 0-0 fg=green
16| "▌ 4016 tests passed "
16| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
17| "▌ 1 test skipped "
style 0-0 fg=green
18| "▌ coverage complete "
@@ -62,6 +63,6 @@ buffer
style 1-1 inverse
23| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 47-79 dim
24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-13 dim
style 22-79 dim
@@ -49,36 +49,19 @@ buffer
19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-65 fg=bright-black
20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ "
20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-13 dim
style 14-85 fg=bright-blue
21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ "
style 2-54 dim
21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 14-14 fg=bright-blue
style 16-76 bold
style 85-85 fg=bright-blue
22| "▌ [signal SIG\\│ │ "
22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-0 fg=green
style 2-13 fg=red
style 14-14 fg=bright-blue
style 85-85 fg=bright-blue
23| "▌ │ ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ "
style 2-58 fg=red
23| "▌ "
style 0-0 fg=green
style 14-14 fg=bright-blue
style 16-16 fg=bright-blue inverse
style 17-17 inverse
style 18-18 fg=bright-blue inverse
style 19-78 inverse
style 79-83 fg=bright-black inverse
style 85-85 fg=bright-blue
24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ "
style 14-14 fg=bright-blue
style 16-65 dim
style 85-85 fg=bright-blue
25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ "
style 1-13 dim
style 14-85 fg=bright-blue
24| <blank>
25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 dim
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-60 fg=bright-black
27| <blank>
@@ -88,19 +71,17 @@ buffer
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
31| <blank>
32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
33| <blank>
34| "Plan"
style 0-3 fg=bright-blue bold
35| " Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 2-2 fg=yellow
36| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
37| " "
style 1-1 inverse
38| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
32| " "
33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black
34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
35| " "
36| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-blue bold
style 67-97 fg=bright-black
37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
style 2-64 dim
38| " "
39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 67-99 dim
style 42-99 dim
+60 -33
View File
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -40,6 +41,8 @@ const CHECKPOINTS = [
'surface-before-compaction',
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'model-selector',
'model-switching',
'errors-and-help',
'disposed-terminal',
] as const
@@ -98,7 +101,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
async function configureAdvancedTools(ctx: Context): Promise<void> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
ctx.provide('workflows', {} as never)
ctx.provide('workflows', { start() {} } as never)
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
}
@@ -119,7 +122,7 @@ function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): v
for (const call of calls) {
session.append('tool/call', {
turn: 1,
step: 0,
step: 1,
callId: CallId(call.id),
name: call.name,
arguments: JSON.stringify(call.arguments),
@@ -135,7 +138,7 @@ function appendToolResult(
): void {
session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId(id),
content,
isError: options.isError ?? false,
@@ -201,25 +204,27 @@ describe('TUI terminal-state snapshots', () => {
it('pins an in-flight reasoning and Markdown stream', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
@@ -426,9 +431,10 @@ describe('TUI terminal-state snapshots', () => {
source: { kind: 'user' },
reason: `Unsafe policy ${CONTROL_PROBE}`,
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 7,
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
turn: 1,
reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` },
})
},
}, { columns: 100, rows: 34 })
@@ -450,7 +456,7 @@ describe('TUI terminal-state snapshots', () => {
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await harness.terminal.waitForFrame(beforeQuestion)
await renderAfter(harness, () => {
harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
})
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
@@ -463,25 +469,29 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
config: {
maxQuestionOptions: 3,
questionDialogWidth: 48,
questionDialogWidth: 200,
questionDialogMaxHeight: 16,
},
}, { columns: 56, rows: 20 })
const controller = new AbortController()
const beforeQuestion = harness.terminal.frames
const answer = harness.ctx.userInteraction.ask({
questions: [{
id: 'coverage',
header: 'Coverage',
question: 'Which advanced TUI states belong in the required matrix?',
multiSelect: true,
options: [
{ label: 'Code Mode', description: 'run_code programs and captured output' },
{ label: 'Workflows', description: 'phases and parallel agents' },
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
{ label: 'Compaction', description: 'surface replacement and reflow' },
],
}],
questions: [
{
id: 'coverage',
header: 'Coverage',
question: 'Which advanced TUI states belong in the required matrix?',
multiSelect: true,
options: [
{ label: 'Code Mode', description: 'run_code programs and captured output' },
{ label: 'Workflows', description: 'phases and parallel agents' },
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
{ label: 'Compaction', description: 'surface replacement and reflow' },
],
},
{ id: 'priority', question: 'Which state should be implemented first?' },
{ id: 'notes', question: 'Any additional constraints?' },
],
signal: controller.signal,
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
@@ -508,14 +518,14 @@ describe('TUI terminal-state snapshots', () => {
}, { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
@@ -551,13 +561,15 @@ describe('TUI terminal-state snapshots', () => {
harness.terminal.send('\r')
harness.terminal.send('/unknown-advanced-command')
harness.terminal.send('\r')
harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output'))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
harness.session.append('step/end', { turn: 1, step: 1 })
harness.session.append('turn/end', {
turn: 3,
turn: 1,
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
})
harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
harness.session.append('turn/end', {
turn: 4,
turn: 2,
reason: { kind: 'interrupted' },
})
})
@@ -569,6 +581,21 @@ describe('TUI terminal-state snapshots', () => {
await harness.ctx.fiber.dispose()
await harness.terminal.dispose()
})
it('pins the model selector and selection notice', async () => {
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/model')
harness.terminal.send('\r')
})
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\x1b[B')
harness.terminal.send('\r')
})
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
})
afterAll(async () => {
+316 -66
View File
@@ -3,7 +3,8 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -112,14 +113,25 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<
await disposeTuiTestHarness(setupResult)
}
function provideTokenMeter(ctx: Context): void {
ctx.provide('tokenMeter', {
measure() {
return { totalTokens: 0 }
},
} as never)
}
describe('TUI config', () => {
it('defaults every direct-call TUI option', () => {
expect(resolveTuiConfig(undefined)).toEqual({
showReasoning: true,
maxToolOutputLines: 12,
maxToolOutputLines: 6,
maxQuestionOptions: 8,
questionDialogWidth: 72,
maxModelOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
showHardwareCursor: false,
color: true,
title: 'DeepSeek Harness',
@@ -128,8 +140,11 @@ describe('TUI config', () => {
showReasoning: false,
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
title: 'DSH',
@@ -137,8 +152,11 @@ describe('TUI config', () => {
showReasoning: false,
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
title: 'DSH',
@@ -148,7 +166,11 @@ describe('TUI config', () => {
describe('pi-tui chat lifecycle and transcript', () => {
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
let now = 0
const result = await setup({
contextWindow: 100,
contextTokens: 42,
now: () => now,
beforeMount(session) {
appendUser(session, 'restored prompt')
appendAssistant(session, [
@@ -174,9 +196,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('restored answer')
expect(result.terminal.output).toContain('write tests')
expect(result.terminal.output).toContain('↑1.3k ↓42')
expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)')
result.terminal.resize(52)
await tick()
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
result.terminal.resize(65)
await tick()
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
result.terminal.resize(88)
await tick()
result.agent.status = 'running'
result.ctx.emit('agent/status', result.agent, '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' })
@@ -184,62 +216,65 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
appendAssistant(result.session, [])
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
result.session.append('step/start', { turn: 11, step: 0 })
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('step/start', { turn: 3, step: 1 })
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
})
await tick()
@@ -250,33 +285,35 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session,
[{ type: 'text', text: 'final live answer' }],
{ inputTokens: 500, outputTokens: 8 },
{ turn: 2, step: 0 },
{ turn: 3, step: 1 },
)
await tick()
expect(result.terminal.output).toContain('Working')
expect(result.terminal.output).toContain('Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
expect(result.terminal.output).toContain('Steering')
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.output).toContain('final live answer')
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.progress).toContain(true)
result.session.append('assistant/chunk', {
turn: 3,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
})
result.terminal.send('/clear')
result.terminal.send('\r')
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
await tick()
expect(result.terminal.output).toContain('answer after clear')
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
await tick()
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
expect(result.terminal.stopped).toBe(1)
@@ -393,8 +430,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendUser(session, 'first prompt')
appendUser(session, 'second prompt')
session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
})
},
@@ -446,6 +483,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
result.agent.status = 'running'
result.ctx.emit('agent/status', result.agent, 'running')
result.terminal.send('steer it')
result.terminal.send('\r')
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
@@ -500,6 +538,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(disposedAgent)
})
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
const result = await setup({
agentOptions: { provider: 'alpha', model: 'a1' },
contextTokens: 50,
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
models: [
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
{ provider: 'alpha', id: 'shared', name: 'Alpha Shared' },
{ provider: 'beta', id: 'b1', name: 'Beta One' },
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
],
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
? initialContext.promise
: Promise.resolve({ contextWindow: 200 }),
},
})
for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) {
result.terminal.send(command)
result.terminal.send('\r')
await tick()
}
expect(result.terminal.output).toContain('Usage: /model')
expect(result.terminal.output).toContain('Unknown model: missing')
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
result.agent.status = 'running'
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select model')
expect(result.terminal.output).toContain('alpha/a1')
expect(result.terminal.output).toContain('Alpha One — Fast — current')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Model selected: beta/b1')
expect(result.agent.sent).toEqual([])
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
await tick()
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
result.terminal.send('\x1b')
await tick()
expect(result.agent.cancelled).not.toContain('cancelled from terminal')
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
const request = await agentEvents(result.ctx, result.agent).waterfall(
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
)
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
await dispose(result)
})
it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => {
const resumed = await setup({
agentOptions: { provider: 'alpha', model: 'configured' },
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'beta', model: 'private' } },
reason: 'initial',
})
},
})
resumed.terminal.send('/model')
resumed.terminal.send('\r')
await tick()
expect(resumed.terminal.output).toContain('Select model')
expect(resumed.terminal.output).toContain('beta/private')
expect(resumed.terminal.output).toContain('private — current')
await dispose(resumed)
const unset = await setup({
agentOptions: {},
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelContext: () => Promise.resolve(undefined),
},
})
unset.terminal.send('/model')
unset.terminal.send('\r')
await tick()
unset.terminal.send('\r')
await tick()
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
await dispose(unset)
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
empty.terminal.send('/model')
empty.terminal.send('\r')
await tick()
expect(empty.terminal.output).toContain('Current model: unset')
expect(empty.terminal.output).toContain('No models are advertised')
const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent))
expect(assembly.variables).toEqual({})
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await dispose(empty)
const failed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
},
})
failed.terminal.send('/model')
failed.terminal.send('\r')
await tick()
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
await dispose(failed)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => deferred.promise,
},
})
result.terminal.send('/model')
result.terminal.send('\r')
await result.controller.dispose()
deferred.resolve([])
await tick()
expect(result.terminal.output).not.toContain('Available models')
await result.ctx.fiber.dispose()
const rejected = Promise.withResolvers<never[]>()
const rejectedResult = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => rejected.promise,
},
})
rejectedResult.terminal.send('/model')
rejectedResult.terminal.send('\r')
await rejectedResult.controller.dispose()
rejected.reject(new Error('late catalog failure'))
await tick()
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
await rejectedResult.ctx.fiber.dispose()
const context = Promise.withResolvers<{ contextWindow: number }>()
const contextResult = await setup({
contextTokens: 99,
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
resolveModelContext: () => context.promise,
},
})
await contextResult.controller.dispose()
context.resolve({ contextWindow: 100 })
await tick()
expect(contextResult.terminal.output).not.toContain('99% context')
await contextResult.ctx.fiber.dispose()
})
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
const result = await setup()
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
@@ -613,22 +834,30 @@ describe('pi-tui chat lifecycle and transcript', () => {
const events = await setup()
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
events.ctx.emit('agent/status', unrelatedAgent, 'running')
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
events.ctx.emit('agent/disposed', unrelatedAgent)
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted' } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
events.session.append('step/end', { turn: 1, step: 1 })
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } })
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', {
turn: 9,
turn: 7,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
})
events.ctx.emit('agent/disposed', events.agent)
agentEvents(events.ctx, events.agent).emit('agent/disposed')
await tick()
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
@@ -701,7 +930,7 @@ describe('tool cards and surface replay', () => {
}
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
const result = await setup({ tools, config: { maxToolOutputLines: 4 } })
const calls = [
['c1', 'bash', '{"command":"printf hello"}'],
['c2', 'signal', '{}'],
@@ -722,7 +951,7 @@ describe('tool cards and surface replay', () => {
})),
])
for (const [id, name, args] of calls) {
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args })
}
await tick()
expect(result.terminal.output).toContain('$ raw command')
@@ -732,23 +961,23 @@ 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: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
turn: 1, step: 1, 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: 0, callId: 'c7' as never,
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' }] },
@@ -757,20 +986,25 @@ describe('tool cards and surface replay', () => {
isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
turn: 1, step: 1, 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: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
turn: 1, step: 1, 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: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
turn: 1,
step: 1,
callId: 'orphan' as never,
content: [{ type: 'text', text: 'orphan result' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
await tick()
const output = result.terminal.output
expect(output).toContain('Run command')
expect(output).toContain('printf hello')
expect(output).toContain('more lines')
expect(output).toContain('lines (Ctrl+O to expand)')
expect(output).toContain('SIGTERM')
expect(output).toContain('Edit files')
expect(output).toContain('Inspected')
@@ -787,6 +1021,11 @@ describe('tool cards and surface replay', () => {
result.terminal.send('/redraw')
result.terminal.send('\r')
await tick()
const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(collapsed).toContain('Run command')
expect(collapsed).toContain('[exit 0]')
expect(collapsed).not.toContain('▌ hello')
expect(collapsed).not.toContain('world')
result.terminal.send('\x0f')
await tick()
expect(result.terminal.output).toContain('world')
@@ -799,15 +1038,15 @@ describe('tool cards and surface replay', () => {
appendUser(result.session, 'old prompt')
const assistant = result.session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
result.session.append('tool/call', {
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
})
const toolResult = result.session.append('tool/result', {
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
turn: 1, step: 1, 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('context/message', {
@@ -840,6 +1079,7 @@ describe('TUI user-interaction dialogs', () => {
})
await tick()
expect(result.terminal.output).toContain('Choose a mode')
expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode')
expect(result.terminal.output).toContain('1/2')
result.terminal.send('\x1b[B')
result.terminal.send('\r')
@@ -859,7 +1099,7 @@ describe('TUI user-interaction dialogs', () => {
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
})
await tick()
result.terminal.send('c')
result.terminal.send('\t')
result.terminal.send('my choice')
result.terminal.send('\r')
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
@@ -933,9 +1173,11 @@ describe('TUI user-interaction dialogs', () => {
],
})
await tick()
expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Second?')
expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)')
result.terminal.send('done')
result.terminal.send('\r')
await expect(batch).resolves.toEqual({ answers: [
@@ -982,6 +1224,7 @@ describe('TUI user-interaction dialogs', () => {
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1001,6 +1244,7 @@ describe('terminal mounting', () => {
it('waits for its configured agent before starting the TUI', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1030,6 +1274,7 @@ describe('terminal mounting', () => {
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1058,6 +1303,7 @@ describe('terminal mounting', () => {
it('renders an uncoercible startup failure without escaping the display boundary', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1079,12 +1325,15 @@ describe('terminal mounting', () => {
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('failed-start-session'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
@@ -1102,7 +1351,7 @@ describe('terminal mounting', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {
turn: 1,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
})
await tick()
@@ -1112,6 +1361,7 @@ describe('terminal mounting', () => {
it('throws when createTuiChat is called without the configured agent', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
+6
View File
@@ -26,6 +26,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../llm/llm-retry"
},
@@ -37,6 +40,9 @@
},
{
"path": "../user-interaction"
},
{
"path": "../../support/invariants"
}
]
}
+7
View File
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -24,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -36,6 +42,7 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -0,0 +1,93 @@
/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ApprovalRequestId } from './index.ts'
import { APPROVAL_POLICIES } from './index.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval'
const APPROVAL_OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] as const
/** Cordis companion plugin name. */
export const name = 'user-approval-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
type ApprovalTransition =
| { kind: 'asked'; id: ApprovalRequestId }
| { kind: 'decided'; id: ApprovalRequestId }
/** Validate one approval event against committed unmatched questions. */
function validateApprovalEvent(
pending: ReadonlySet<ApprovalRequestId>,
event: SessionEvent,
fail: InvariantFailure,
): ApprovalTransition | undefined {
if (event.type === 'approval/asked') {
if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty')
if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
return { kind: 'asked', id: event.data.id }
}
if (event.type === 'approval/decided') {
if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`)
if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) {
fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`)
}
return { kind: 'decided', id: event.data.id }
}
if (event.type === 'approval/policy' && !APPROVAL_POLICIES.includes(event.data.policy)) {
fail(`approval/policy carries unknown policy ${JSON.stringify(event.data.policy)}`)
}
return undefined
}
/** Apply one accepted approval-pair transition. */
function applyApprovalTransition(pending: Set<ApprovalRequestId>, transition: ApprovalTransition): void {
if (transition.kind === 'asked') pending.add(transition.id)
else pending.delete(transition.id)
}
/** Install audit pairing and closed-vocabulary checks. */
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
/* jscpd:ignore-start */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, Set<ApprovalRequestId>>()
const staged = new WeakMap<SessionEvent, { session: Session; transition: ApprovalTransition }>()
const seed = (session: Session): Set<ApprovalRequestId> => {
const pending = new Set<ApprovalRequestId>()
traces.set(session, pending)
for (const event of session.events) {
const transition = validateApprovalEvent(pending, event, fail)
if (transition !== undefined) applyApprovalTransition(pending, transition)
}
return pending
}
const traceFor = (session: Session): Set<ApprovalRequestId> => traces.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => {
if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every package-owned pair event */
if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation')
staged.delete(event)
applyApprovalTransition(traceFor(session), candidate.transition)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const transition = validateApprovalEvent(traceFor(session), event, fail)
if (transition !== undefined) staged.set(event, { session, transition })
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the approval invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
@@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => {
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
ctx.serial('agent/pre-step', agent, 1, 1, new AbortController().signal)
agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal)
/** Append a `request/header` snapshot whose system text is exactly `system`. */
function appendHeader(session: Session, system: string): void {
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(ApprovalInvariant)
return ctx
}
describe('approval invariants', () => {
it('accepts paired audit events and closed policy values', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
const id = ApprovalRequestId('ask-1')
session.append('approval/asked', { id, toolName: 'bash' })
session.append('approval/decided', { id, outcome: 'allowed-once' })
session.append('approval/policy', { policy: 'never' })
})
it('rebuilds an unmatched question from an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const id = ApprovalRequestId('ask-resume')
session.append('approval/asked', { id, toolName: 'bash' })
await ctx.plugin(InvariantService)
await ctx.plugin(ApprovalInvariant)
expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
it('adopts a bare session first observed through publication', async () => {
const ctx = await setup()
const session = new Session(SessionId('bare-approval-session'))
const id = ApprovalRequestId('bare-ask')
const asked = {
type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' },
} as const
const decided = {
type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const },
} as const
expect(() => {
ctx.emit('session/event', session, asked)
ctx.emit('session/event', session, decided)
}).not.toThrow()
})
it('rejects malformed and unpaired audit events', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
const id = ApprovalRequestId('ask-1')
expect(() => session.append('approval/asked', { id, toolName: '' }))
.toThrow(/toolName must be non-empty/)
session.append('approval/asked', { id, toolName: 'bash' })
expect(() => session.append('approval/asked', { id, toolName: 'bash' }))
.toThrow(/repeated open id/)
expect(() => session.append('approval/decided', {
id: ApprovalRequestId('missing'), outcome: 'rejected',
})).toThrow(/no matching approval\/asked/)
expect(() => session.append('approval/decided', { id, outcome: 'maybe' as never }))
.toThrow(/unknown outcome/)
expect(() => session.append('approval/policy', { policy: 'always' as never }))
.toThrow(/unknown policy/)
})
})
+3
View File
@@ -34,6 +34,9 @@
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
}
]
}
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,11 +28,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-user-interaction`.
* @module @deepseek-ai/dsh-user-interaction/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction'
/** Cordis companion plugin name. */
export const name = 'user-interaction-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the single provider slot is validated at registration and asks return
* directly to their caller; the seam publishes no independent request/answer audit stream.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -19,6 +19,9 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
}
]
}