Merge remote-tracking branch 'origin/master' into feat/send-unify

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
Turtle
2026-07-23 22:41:45 +08:00
333 changed files with 10751 additions and 907 deletions

View File

@@ -17,7 +17,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem
The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan <message>` also submits the message into that step. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan <message>` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
### Resuming a prior session
@@ -50,14 +50,14 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| Entry | Demonstrates |
|---|---|
| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:tui` passes |
| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access |
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice |
| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend |
| `plan-mode` | the plugin-owned `/plan [message]` command, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition |
| `plan-mode` | the plugin-owned `/plan [message]` entry and `/plan off` exit commands, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition |
| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace |
## End-to-end tests (`pnpm run test:e2e`)

View File

@@ -1,11 +1,10 @@
# Full-screen TUI coding agent with swappable DeepSeek and local-bash backends.
# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic
# task controls, JSONL persistence, the pi-tui front door, and `main`.
# HMR remains a leaf because it requires Loader internals; `demo:tui` passes
# `--expose-internals`. The app bin loads the gitignored root `.env`; this file
# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`.
# HMR remains a leaf because it depends on Loader internals. The app bin loads
# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional
# `DEEPSEEK_BASE_URL` through `!!js`.
# Hot-module reload for the dev/demo loop (needs `node --expose-internals`).
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
@@ -122,8 +121,8 @@
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# Plan mode gives the TUI a plugin-owned /plan [message] command; the exit
# review rides the TUI's user-interaction provider.
# Plan mode gives the TUI plugin-owned /plan [message] entry and /plan off exit
# commands; the reviewed exit rides the TUI's user-interaction provider.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:

View File

@@ -5,6 +5,8 @@ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1'
const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}`
const FINAL_TEXT = 'Decision received. Scripted TUI run complete.'
const DEFAULT_MODE_PROBE = 'Confirm the scripted run left plan mode.'
const DEFAULT_MODE_TEXT = 'Default mode confirmed.'
// The `skill` scenario types `/skill:scripted-skill`; the manual-invocation front
// door delivers the loaded skill as a user turn wrapped in `<skill name="…">`. The
// body marker below lives in the fixture skill, so echoing it back proves the whole
@@ -53,6 +55,13 @@ class ScriptedTuiAdapter extends LlmAdapter {
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
if (lastText.includes(DEFAULT_MODE_PROBE)) {
if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) {
throw new Error('the scripted TUI request retained plan guidance after /plan off')
}
for (const chunk of textChunks(DEFAULT_MODE_TEXT)) yield chunk
return
}
if (lastText.includes(SKILL_BLOCK_OPEN)) {
const ack = lastText.includes(SKILL_BODY_MARKER)
? SKILL_RECEIVED_TEXT

View File

@@ -197,7 +197,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
/* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
: [options.configPath ?? './cordis.yml'],
tsconfigPath: options.tsconfigPath,
exposeInternals: true,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),

View File

@@ -1,7 +1,7 @@
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "Reply with exactly the word: — DSH TUI snapshot"
cursor hidden column=1 viewportRow=28 bufferRow=28
cursor hidden column=1 viewportRow=33 bufferRow=33
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -11,8 +11,8 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Entering plan mode (applies from the next step). "
style 1-48 fg=bright-black
4| " Entering plan mode (applies from the next step). Use /plan off to leave. "
style 1-72 fg=bright-black
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
@@ -33,31 +33,38 @@ buffer
style 1-9 fg=bright-magenta bold
15| " ONE "
16| <blank>
17| "▌ "
17| " Leaving plan mode (applies from the next step). "
style 1-47 fg=bright-black
18| <blank>
19| " Context · plan-mode "
style 1-19 dim
20| " The user switched this session back to the default mode. "
style 1-56 fg=bright-black
21| <blank>
22| "▌ "
style 0-0 fg=bright-blue
18| "▌ You "
23| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
19| "▌ Reply with exactly the word: TWO. No tools. "
24| "▌ Reply with exactly the word: TWO. No tools. "
style 0-0 fg=bright-blue
20| "▌ "
25| "▌ "
style 0-0 fg=bright-blue
21| <blank>
22| " Reasoning "
26| <blank>
27| " Reasoning "
style 1-9 fg=bright-black italic
23| " The user wants me to reply with exactly the word \"TWO\" and no tools. "
28| " The user wants me to reply with exactly the word \"TWO\" and no tools. "
style 1-68 fg=bright-black italic
24| <blank>
25| " Assistant "
29| <blank>
30| " Assistant "
style 1-9 fg=bright-magenta bold
26| " TWO "
27| "────────────────────────────────────────────────────────────────────────────────────────────────────"
31| " TWO "
32| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
28| " "
33| " "
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────────────"
34| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
30| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co"
35| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co"
style 0-92 dim
style 95-99 dim
31-35| <blank>

View File

@@ -87,14 +87,16 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
const output = await smoke({
label: 'tui-agent boot',
actions: [
{ waitFor: 'main-session-', send: '/plan\r' },
{ waitFor: 'Entering plan mode (applies from the next step).', send: '/exit\r' },
{ waitFor: 'main-session-', send: '/plan' },
{ waitFor: '[off|message] — Enter or leave plan mode', send: '\r' },
{ waitFor: 'Entering plan mode (applies from the next step). Use /plan off to leave.', send: '/exit\r' },
],
})
expect(output).toContain('DEEPSEEK')
expect(output).toContain('HARNESS')
expect(output).toContain('main-session-')
expect(output).toContain('Entering plan mode (applies from the next step).')
expect(output).toContain('[off|message] — Enter or leave plan mode')
expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
// Borderless: no box-drawing frame around the banner.
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
@@ -120,12 +122,16 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
// window title as `<session title> — <configured title>` via OSC 0.
// Gating /status on it keeps the assertion race-free; the diagnostics
// card is then exercised through the same real Loader/PTY composition.
{ waitFor: 'scripted session title — DeepSeek Harness', send: '/status\r' },
{ waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' },
{ waitFor: 'Leaving plan mode (applies from the next step).', send: 'Confirm the scripted run left plan mode.\r' },
{ waitFor: 'Default mode confirmed.', send: '/status\r' },
{ waitFor: 'Session status', send: '/exit\r' },
],
})
expect(output).toContain('I need one decision before I continue.')
expect(output).toContain('Entering plan mode (applies from the next step).')
expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
expect(output).toContain('Leaving plan mode (applies from the next step).')
expect(output).toContain('Default mode confirmed.')
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
expect(output).toContain(String.raw`\x9b31mMODEL_C1`)

View File

@@ -47,6 +47,7 @@ interface Scenario {
expectedEventCounts?: Record<string, number>
childSessions?: number
enterPlanMode?: boolean
leavePlanModeAfterFirstTurn?: boolean
recorded: boolean
seedWorkspace?: boolean
/**
@@ -62,8 +63,9 @@ const SCENARIOS: Scenario[] = [
name: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
expectedEventCounts: { 'plan/mode': 1 },
expectedEventCounts: { 'plan/mode': 2 },
enterPlanMode: true,
leavePlanModeAfterFirstTurn: true,
recorded: true,
},
{
@@ -294,6 +296,12 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
remainingPrompts = prompts.slice(1)
}
if (scenario.leavePlanModeAfterFirstTurn === true) {
terminal.send('/plan off')
terminal.send('\r')
await settleTerminal(terminal)
}
for (const prompt of remainingPrompts) {
terminal.send(prompt)
terminal.send('\r')
@@ -310,7 +318,9 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
}
if (scenario.enterPlanMode === true) {
expect(ctx.planMode.get(agent)).toEqual({ active: true })
expect(ctx.planMode.get(agent)).toEqual({
active: scenario.leavePlanModeAfterFirstTurn !== true,
})
const planMode = events.find(event => event.type === 'plan/mode')
if (planMode === undefined || firstHeader === undefined) {
throw new Error('plan-mode command snapshot needs plan/mode before its first request/header')
@@ -320,6 +330,20 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const firstMessage = events.find(event => event.type === 'user/message')
expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }])
}
if (scenario.leavePlanModeAfterFirstTurn === true) {
const planModes = events.filter(event => event.type === 'plan/mode')
expect(planModes.map(event => event.data.active)).toEqual([true, false])
const headers = events.filter(event => event.type === 'request/header')
const exit = planModes[1]
const afterExit = headers[1]
if (exit === undefined || afterExit === undefined) {
throw new Error('active plan exit snapshot needs a committed exit and changed request header')
}
expect(exit.seq).toBeLessThan(afterExit.seq)
expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.')
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
}
expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true)
expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true)
if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') {