Merge remote-tracking branch 'origin/master' into worktree/llm-reasoning-effort
# Conflicts: # docs/architecture.i18n.yaml # docs/config-catalog.md
This commit is contained in:
@@ -17,4 +17,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
|
||||
|
||||
The runnable app bundles that bake these interfaces into boot bins live in [`examples/`](../examples/README.md), composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md). `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
|
||||
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
|
||||
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
|
||||
* against a leaf `cordis.yml` until the whole tree has settled.
|
||||
@@ -36,62 +36,6 @@ export function resolveConfigPath(
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
|
||||
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
|
||||
const RESUME_FLAG = '--resume'
|
||||
|
||||
/**
|
||||
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
|
||||
* vector, returning the resumed session id (when the flag is present) and the
|
||||
* remaining arguments with the flag and its value removed — so a positional
|
||||
* config path stays readable regardless of the flag's position. A `--resume`
|
||||
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
|
||||
* throws: a mistyped resume must fail loud, never silently start a fresh
|
||||
* session. The id is not validated here; an unknown id fails loud downstream
|
||||
* when the session cannot load.
|
||||
* @param argv - the CLI arguments after subcommand dispatch.
|
||||
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
|
||||
*/
|
||||
export function parseResumeArg(
|
||||
argv: readonly string[],
|
||||
): { resumeSessionId: string | undefined; rest: string[] } {
|
||||
const rest: string[] = []
|
||||
let resumeSessionId: string | undefined
|
||||
let skipNext = false
|
||||
for (const [i, arg] of argv.entries()) {
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
|
||||
if (arg === RESUME_FLAG || inlineValue) {
|
||||
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
|
||||
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
|
||||
// A following token that is itself resume syntax (`--resume --resume x`)
|
||||
// is a missing id, not a session literally named `--resume…`.
|
||||
if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
|
||||
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
|
||||
}
|
||||
resumeSessionId = value
|
||||
skipNext = !inlineValue // the space form consumed the following token as its value
|
||||
continue
|
||||
}
|
||||
rest.push(arg)
|
||||
}
|
||||
return { resumeSessionId, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
|
||||
* @param argv - current arguments after command dispatch.
|
||||
* @param sessionId - selected session id.
|
||||
* @returns flag-normalized arguments for a process replacement.
|
||||
*/
|
||||
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
|
||||
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
|
||||
const { rest } = parseResumeArg(argv)
|
||||
return [...rest, RESUME_FLAG, sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
@@ -212,6 +156,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
|
||||
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
|
||||
* makes `id` readable as the bare identifier `resumeSessionId` in a config
|
||||
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
|
||||
* so resuming a session needs no environment variable. A bin that never
|
||||
* provides it leaves the identifier undeclared, so configs read it defensively
|
||||
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
|
||||
*/
|
||||
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
* tree settles. Entry names load through the Loader's internal module loader
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -30,40 +30,6 @@ describe('resolveConfigPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseResumeArg', () => {
|
||||
it('returns no resume id and passes arguments through when the flag is absent', () => {
|
||||
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
|
||||
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
|
||||
})
|
||||
|
||||
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
|
||||
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
|
||||
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
|
||||
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
|
||||
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
|
||||
})
|
||||
|
||||
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
|
||||
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
|
||||
})
|
||||
|
||||
it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
|
||||
expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceResumeArg', () => {
|
||||
it('keeps positional arguments and replaces either existing flag form', () => {
|
||||
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
|
||||
@@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService {
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
..._args: Parameters<SessionQueryService['searchEvents']>
|
||||
...args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
return this.readSurface(args[0].sessionId).then(surface => ({
|
||||
session: surface.session,
|
||||
items: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,7 +675,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
|
||||
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
|
||||
const harness = await setupSnapshot({
|
||||
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
|
||||
config: { resumeCommand: 'dsh --resume {session}' },
|
||||
sessionPersistence: {
|
||||
list: async () => [earlier],
|
||||
load: async () => ({
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('TUI config', () => {
|
||||
})
|
||||
|
||||
describe('resume command and /resume', () => {
|
||||
const RESUME = 'RESUME_SESSION_ID={session} dsh'
|
||||
const RESUME = 'dsh --resume {session}'
|
||||
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
|
||||
({ version: 0, id: SessionId(id), createdAt, cwd })
|
||||
const resumeEvents = (
|
||||
@@ -242,7 +242,7 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh')
|
||||
expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
await dispose(result)
|
||||
})
|
||||
@@ -1008,7 +1008,7 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
|
||||
expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session')
|
||||
expect(result.terminal.output).toContain('dsh --resume fallback-session')
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user