feat(cli): even out the shipped tool rosters across both surfaces
The two shipped surfaces offered different tools for no recorded reason: session checkpoints, tool-result pruning, the goal tools, and Ralph were in `tui.cordis.yml`; `tool-todo` and web search were in `web.cordis.yml`. Neither offered session search, a string-replacement editor, or a repeat-tool guard, though none of the three is surface-specific. Move the rows that are not surface-specific into `base.cordis.yml` and add those three. Web search moves there too — the TUI decision the change that made it a Web default deferred. Both surfaces now assemble the same 27 tools. This adds only. No row is removed from either surface and no existing row's configuration is edited: executors, sandbox composition, access defaults, `tools.mode`, and the workflow tool are exactly what they were. Two rows stay surface-specific: `tmux-context` (no terminal multiplexer in a browser) and `session-reference` (its index has one writer owner). Ship `dsh-mcp-client` as a dependency without a row: the plugin mounts one server per instance and `command` is required, so a default would name a third-party server and spawn it outside `ctx.bash` on every launch. The CLI README carries the YAML for mounting one from a personal config.
This commit is contained in:
51
apps/cli/tests/fixtures/composition-echo-llm.ts
vendored
Normal file
51
apps/cli/tests/fixtures/composition-echo-llm.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */
|
||||
export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.'
|
||||
|
||||
// Provider id and model the keyless tail routes `main` to; that overlay is the
|
||||
// only caller, so the pair lives here as plain constants.
|
||||
const COMPOSITION_PROVIDER = 'composition-keyless'
|
||||
const COMPOSITION_MODEL = 'composition-keyless-model'
|
||||
|
||||
/**
|
||||
* Network-free adapter for the shipped-composition smoke. It answers every
|
||||
* request — tool-ful agent turns and the tool-less auxiliary calls alike — with
|
||||
* one fixed text and never calls a tool, because the assertion under test is the
|
||||
* assembled tool catalog the loop logs, not any tool's behavior.
|
||||
*/
|
||||
class CompositionEchoAdapter extends LlmAdapter {
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }])
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } })
|
||||
}
|
||||
|
||||
override async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } }
|
||||
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'composition-echo-llm'
|
||||
export const inject = ['llm']
|
||||
|
||||
/**
|
||||
* Register the network-free adapter the shipped-composition smoke routes through.
|
||||
* @param ctx - the loader-mounted plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter())
|
||||
}
|
||||
52
apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml
vendored
Normal file
52
apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Keyless tail for the shipped-composition smoke, applied as `--config` so the
|
||||
# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file.
|
||||
#
|
||||
# Everything below is test isolation, never composition under test: the model is
|
||||
# replaced so no request leaves the process, the settle marker gates the smoke's
|
||||
# first prompt, and the session artifacts move into the smoke's temporary
|
||||
# workspace so the log inspection can read them.
|
||||
|
||||
# A patch's `name` is an assertion rather than a replacement, so the base
|
||||
# adapter row is disabled and the scripted one inserted. Relative specifiers
|
||||
# resolve against the INCLUDED file's directory (apps/cli/config), not this
|
||||
# file's, because the include moves baseUrl there.
|
||||
- id: llm-deepseek
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: composition-echo-llm
|
||||
name: '../tests/fixtures/composition-echo-llm.ts'
|
||||
- id: composition-settled
|
||||
name: '../tests/fixtures/composition-settled.ts'
|
||||
|
||||
- id: agent-loop
|
||||
config:
|
||||
agents:
|
||||
- id: main
|
||||
provider: composition-keyless
|
||||
model: composition-keyless-model
|
||||
cwd: !!js process.cwd()
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
config:
|
||||
root: './.sessions'
|
||||
compression: none
|
||||
|
||||
- id: session-query-sqlite
|
||||
config:
|
||||
path: './.sessions/session-query.db'
|
||||
|
||||
# The title call is a second, tool-less request that would race the log
|
||||
# inspection for no coverage: the catalog under test rides the agent turn.
|
||||
- id: session-title-llm
|
||||
disabled: true
|
||||
|
||||
- id: tui
|
||||
config:
|
||||
sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'
|
||||
welcome: 'composition smoke ready.'
|
||||
showReasoning: true
|
||||
|
||||
# HMR watches the repository; a PTY subprocess test must not start a watcher.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
24
apps/cli/tests/fixtures/composition-settled.ts
vendored
Normal file
24
apps/cli/tests/fixtures/composition-settled.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/**
|
||||
* Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as
|
||||
* its own fiber starts, so a prompt typed at the banner can reach the loop while
|
||||
* later rows — tool plugins, persistence — are still activating, and would
|
||||
* assemble a partial catalog. Waiting for this line makes the turn observe the
|
||||
* settled tree.
|
||||
*/
|
||||
export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED'
|
||||
|
||||
export const name = 'composition-settled'
|
||||
|
||||
/**
|
||||
* Announce settled Loader activation on the terminal byte stream, after every
|
||||
* entry in the booted tree has started. The write is detached: awaiting the
|
||||
* Loader from inside an entry would wait on this entry's own activation.
|
||||
* @param ctx - the loader-mounted plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
void ctx.loader.await().then(() => {
|
||||
process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`)
|
||||
})
|
||||
}
|
||||
126
apps/cli/tests/shipped-composition.e2e.ts
Normal file
126
apps/cli/tests/shipped-composition.e2e.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts'
|
||||
import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts'
|
||||
import { runTuiPtySmoke } from './pty-harness.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
// An overlay over the shipped tree, so the catalog under test is the one
|
||||
// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model
|
||||
// and redirects session artifacts.
|
||||
const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url))
|
||||
|
||||
/**
|
||||
* The catalog the shipped `dsh` TUI puts in front of the model, as the loop
|
||||
* logged it, minus the ripgrep-dependent pair below.
|
||||
* The absences are the composition's security decisions, not incidental gaps:
|
||||
* `workflow` and the `cordis_*` toolset both execute model-written JavaScript
|
||||
* that no sandbox row confines, `web_fetch` chooses its own request target, and
|
||||
* `mcp_*` servers spawn outside `ctx.bash`. `ralph` proves the narrower
|
||||
* alternative survives: it runs a script this repository fixed at build time,
|
||||
* so the engine stays mounted without a model-code tool on top of it. The composition Agent Note owns
|
||||
* the rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TUI_TOOLS = [
|
||||
'ask_user_question',
|
||||
'bash',
|
||||
'create_goal',
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'ralph',
|
||||
'read',
|
||||
'session_event_read',
|
||||
'session_event_search',
|
||||
'session_event_trace',
|
||||
'session_search',
|
||||
'session_trace',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
'task_list',
|
||||
'task_output',
|
||||
'todo_write',
|
||||
'update_goal',
|
||||
'web_search',
|
||||
'workflow',
|
||||
'write',
|
||||
]
|
||||
|
||||
/**
|
||||
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
|
||||
* through the mounted bash executor at load and registers neither tool when
|
||||
* ripgrep is absent. That is a host dependency, not a composition decision, so the
|
||||
* pair is asserted separately — present together or absent together.
|
||||
*/
|
||||
const RIPGREP_TOOLS = ['glob', 'grep']
|
||||
|
||||
/** The assembled request header the smoke asserts on. */
|
||||
interface LoggedHeader {
|
||||
/** Assembled tool names, sorted. */
|
||||
names: string[]
|
||||
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
|
||||
bashArguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the request header the loop assembled for its first request from the
|
||||
* session log the smoke's workspace persisted — the model-visible composition
|
||||
* itself, not a registry projection taken beside it.
|
||||
* @param cwd - the smoke's temporary workspace.
|
||||
* @returns the assembled catalog, system prompt, and `bash` argument shape.
|
||||
*/
|
||||
async function loggedHeader(cwd: string): Promise<LoggedHeader> {
|
||||
const sessionsDir = join(cwd, '.sessions')
|
||||
const entries = await readdir(sessionsDir, { recursive: true })
|
||||
// A single keyless run writes one session log.
|
||||
const logRelPath = entries.find(name => name.endsWith('.jsonl'))
|
||||
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
|
||||
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line) as SessionEvent
|
||||
if (event.type !== 'request/header') continue
|
||||
const tools = event.data.header.tools ?? []
|
||||
const bash = tools.find(schema => schema.name === 'bash')
|
||||
return {
|
||||
names: tools.map(schema => schema.name).sort(),
|
||||
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {},
|
||||
}
|
||||
}
|
||||
throw new Error(`session log ${logRelPath} has no request/header event`)
|
||||
}
|
||||
|
||||
describe('shipped dsh composition (real Loader tree in a PTY)', () => {
|
||||
it('assembles exactly the shipped TUI catalog', async () => {
|
||||
let observed: LoggedHeader | undefined
|
||||
const output = await runTuiPtySmoke({
|
||||
label: 'dsh shipped composition',
|
||||
tempDirPrefix: 'dsh-shipped-tui-',
|
||||
binScript: dshBinScript,
|
||||
tsconfigPath,
|
||||
configPath: keylessTail,
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
actions: [
|
||||
{ waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' },
|
||||
{ waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' },
|
||||
],
|
||||
inspect: async (cwd) => { observed = await loggedHeader(cwd) },
|
||||
})
|
||||
expect(output).toContain(COMPOSITION_REPLY_TEXT)
|
||||
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
|
||||
expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name)))
|
||||
// The TUI mounts the unrestricted local executors, so `tool-bash` emits no
|
||||
// escalation pair. Pinning its absence keeps a later sandbox change from
|
||||
// arriving here unannounced.
|
||||
expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
Reference in New Issue
Block a user