Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/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-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -24,13 +24,13 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['meta'])).toEqual({ mode: 'meta' })
|
||||
expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' })
|
||||
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
|
||||
@@ -39,12 +39,35 @@ describe('parseDshArgs', () => {
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
// Guided fresh-session entries carry nothing: bare mode discriminant only.
|
||||
expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
})
|
||||
|
||||
it('routes the dump flags per surface: composed with the user layer, or shipped only', () => {
|
||||
expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false })
|
||||
expect(parse(['--dump-config', '--config', 'c.yml']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' })
|
||||
expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true })
|
||||
expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
|
||||
expect(parse(['web', '--dump-config', '--config', 'w.yml']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
|
||||
expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
|
||||
// The two dump flags contradict each other; boot-only flags alongside a
|
||||
// dump would be silently ignored; the shipped tree takes no user overlay.
|
||||
expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
|
||||
// A leaked dump flag on a subcommand that has none is a mistyped invocation.
|
||||
expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
|
||||
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
|
||||
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
|
||||
@@ -65,17 +88,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
|
||||
// Same rule for each subcommand that shares no option with the default
|
||||
// surface, so a leaked flag is a typo, not something to ignore.
|
||||
// `meta` fixes its own config tree and always starts fresh, so every
|
||||
// default-surface option is rejected.
|
||||
expect(exitCode(['meta', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
|
||||
// `upgrade` takes no options: any leaked default-surface flag is a
|
||||
// mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
|
||||
// `experimental-meta` fixes its own config tree and always starts fresh,
|
||||
// so every default-surface option is rejected.
|
||||
expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1)
|
||||
// `experimental-upgrade` takes no options: any leaked default-surface flag
|
||||
// is a mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1)
|
||||
// The pre-release command names have no compatibility aliases.
|
||||
expect(exitCode(['meta'])).toBe(1)
|
||||
expect(exitCode(['upgrade'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
||||
@@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest'
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
|
||||
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin], {
|
||||
/**
|
||||
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
|
||||
* + exit code. `env` isolates the Harness home for surfaces that read it.
|
||||
*/
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
describe('dsh --dump-config', () => {
|
||||
let home: string
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
|
||||
it('prints the shipped TUI composition without booting or needing a TTY', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
// Base rows composed with the TUI overlay's surface values, `!!js`
|
||||
// expressions verbatim (unevaluated), and TUI-only inserted rows present.
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
|
||||
expect(stdout).toContain('model: deepseek-v4-pro')
|
||||
expect(stdout).toContain('cwd: !!js process.cwd()')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
|
||||
// Provenance comment separators name each section's source file.
|
||||
expect(stdout).toContain('# == base.cordis.yml')
|
||||
expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
|
||||
expect(stdout).toContain('# == tui.cordis.yml')
|
||||
}, 30_000)
|
||||
|
||||
it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
|
||||
writeFileSync(join(home, 'config.yaml'), [
|
||||
'- id: agent-loop',
|
||||
' config:',
|
||||
' agents:',
|
||||
' - id: main',
|
||||
' provider: custom-provider',
|
||||
' model: custom-model',
|
||||
'- id: only-on-web',
|
||||
' config:',
|
||||
' value: 1',
|
||||
'',
|
||||
].join('\n'))
|
||||
const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain('provider: custom-provider')
|
||||
expect(stdout).not.toContain('model: deepseek-v4-pro')
|
||||
// The personal layer appears in the patched row's provenance and the
|
||||
// skipped-patch warning carries its label.
|
||||
expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
|
||||
expect(stderr).toContain('patch: entry "only-on-web" not found')
|
||||
|
||||
// The shipped view ignores the personal overlay entirely.
|
||||
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
||||
expect(shipped.stdout).not.toContain('custom-provider')
|
||||
expect(shipped.stdout).toContain('model: deepseek-v4-pro')
|
||||
}, 30_000)
|
||||
|
||||
it('composes the web overlay for `dsh web --dump-config`', async () => {
|
||||
const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
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`)
|
||||
})
|
||||
}
|
||||
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Minimal keyless composition for loading example MCP overlays against the
|
||||
# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins
|
||||
# keep this unit test independent of prebuilt workspace artifacts.
|
||||
- id: system-prompt
|
||||
name: cordis:memory-test-system-prompt
|
||||
|
||||
- id: tools
|
||||
name: cordis:memory-test-tools
|
||||
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Node 22 startup-output smoke for the shipped Web CLI composition.
|
||||
*
|
||||
* Only the dedicated Node compatibility gate opts this test in after building
|
||||
* both artifacts; ordinary Vitest inventory deterministically skips it.
|
||||
* The child runs built artifacts under plain Node with the real shipped
|
||||
* config (base.cordis.yml + the web.cordis.yml overlay).
|
||||
* Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
|
||||
* shipped quiescent disposer.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import yaml from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const webDist = join(repoRoot, 'apps/web/dist/index.html')
|
||||
// The web overlay owns the session-query-sqlite lazy-open patch row.
|
||||
const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
|
||||
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
|
||||
|
||||
interface ConfigRow {
|
||||
id?: string
|
||||
config?: { openAt?: unknown }
|
||||
}
|
||||
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
construct: value => String(value),
|
||||
})
|
||||
const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
|
||||
function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
}
|
||||
delete env.DEEPSEEK_BASE_URL
|
||||
delete env.NODE_OPTIONS
|
||||
delete env.NODE_NO_WARNINGS
|
||||
const child = spawn(process.execPath, [
|
||||
builtBin,
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
|
||||
settled = true
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 60_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
rejectRun(error)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
return
|
||||
}
|
||||
resolveRun({ stdout, stderr, code: code ?? -1 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
|
||||
it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
|
||||
expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
|
||||
expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
|
||||
const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
|
||||
const searchRow = rows.find(row => row.id === 'session-query-sqlite')
|
||||
expect(searchRow?.config?.openAt).toBe('first-search')
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
|
||||
try {
|
||||
const result = await runBuiltWeb(cwd)
|
||||
expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, 70_000)
|
||||
})
|
||||
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The third-party memory examples stay config-only. This suite parses every
|
||||
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
|
||||
* only the upstream endpoint with the package-owned keyless MCP fixture and
|
||||
* proves the real Cordis Loader discovers a tool through the generic bridge.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
|
||||
interface ExampleContract {
|
||||
file: string
|
||||
id: string
|
||||
serverName: string
|
||||
transport: 'stdio' | 'streamable-http'
|
||||
pin: string
|
||||
}
|
||||
|
||||
interface InsertedRow {
|
||||
id?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const exampleDir = resolve(root, 'examples/mcp-memory')
|
||||
const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
|
||||
const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
|
||||
|
||||
const examples: ExampleContract[] = [
|
||||
{
|
||||
file: 'memorix.cordis.yml',
|
||||
id: 'memory-memorix',
|
||||
serverName: 'memorix',
|
||||
transport: 'stdio',
|
||||
pin: '1.3.0',
|
||||
},
|
||||
{
|
||||
file: 'mcp-reference-memory.cordis.yml',
|
||||
id: 'memory-mcp-reference',
|
||||
serverName: 'reference_memory',
|
||||
transport: 'stdio',
|
||||
pin: '2026.7.4',
|
||||
},
|
||||
{
|
||||
file: 'engram.cordis.yml',
|
||||
id: 'memory-engram',
|
||||
serverName: 'engram',
|
||||
transport: 'stdio',
|
||||
pin: '1.20.0',
|
||||
},
|
||||
]
|
||||
|
||||
const liveContexts = new Set<Context>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
|
||||
liveContexts.clear()
|
||||
})
|
||||
|
||||
function insertedRow(patches: PatchOptions[]): InsertedRow {
|
||||
expect(patches).toHaveLength(1)
|
||||
const insert = patches[0]?.insert
|
||||
expect(insert).toHaveLength(1)
|
||||
return insert?.[0] as InsertedRow
|
||||
}
|
||||
|
||||
async function waitForTool(ctx: Context, name: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!ctx.tools.schemas().some(schema => schema.name === name)) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 25))
|
||||
}
|
||||
}
|
||||
|
||||
describe('third-party memory MCP example overlays', () => {
|
||||
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
|
||||
const file = resolve(exampleDir, contract.file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
|
||||
|
||||
expect(row.id).toBe(contract.id)
|
||||
expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
|
||||
expect(row.config?.serverName).toBe(contract.serverName)
|
||||
expect(row.config?.transport).toBe(contract.transport)
|
||||
expect(source.split('\n', 1)[0]).toContain(contract.pin)
|
||||
expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
|
||||
expect(source).not.toContain('DEEPSEEK_API_KEY')
|
||||
})
|
||||
|
||||
it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
|
||||
const patches = loadOverlayPatches(
|
||||
'memory-mcp-config-test',
|
||||
resolve(exampleDir, contract.file),
|
||||
)
|
||||
// The static config gate verifies the checked-in bare package specifier.
|
||||
// The unit test maps it to the source module so a clean checkout needs no
|
||||
// prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
|
||||
insertedRow(patches).name = 'cordis:memory-test-mcp-client'
|
||||
const fixturePatch: PatchOptions = {
|
||||
id: contract.id,
|
||||
config: {
|
||||
serverName: contract.serverName,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
env: {},
|
||||
cwd: root,
|
||||
toolCallTimeoutMs: 5_000,
|
||||
},
|
||||
}
|
||||
const ctx = await boot(
|
||||
'memory-mcp-config-test',
|
||||
baseConfig,
|
||||
[...patches, fixturePatch],
|
||||
(ctx) => {
|
||||
liveContexts.add(ctx)
|
||||
ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
|
||||
ctx.loader.builtins['memory-test-tools'] = ToolRegistry
|
||||
ctx.loader.builtins['memory-test-mcp-client'] = McpClient
|
||||
},
|
||||
)
|
||||
await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -6,11 +6,11 @@ import { execa } from 'execa'
|
||||
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const POSIX_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
||||
import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(launch_env_json))
|
||||
env.update({"COLUMNS": "100", "LINES": "30"})
|
||||
env.update({"COLUMNS": columns, "LINES": rows})
|
||||
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
|
||||
# banner to the per-letter gradient (one SGR per letter), breaking literal
|
||||
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
|
||||
@@ -20,6 +20,7 @@ pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0))
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
@@ -36,9 +37,14 @@ while time.monotonic() < deadline:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
while action_index < len(actions):
|
||||
marker = actions[action_index]["waitFor"].encode()
|
||||
if output.count(marker) < actions[action_index].get("occurrence", 1):
|
||||
break
|
||||
action = actions[action_index]
|
||||
if "writeFile" in action:
|
||||
if "signal" in action:
|
||||
os.kill(pid, getattr(signal, action["signal"]))
|
||||
elif "writeFile" in action:
|
||||
target = os.path.join(cwd, action["writeFile"]["path"])
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
@@ -68,9 +74,15 @@ if actual_exit != int(expected_exit):
|
||||
|
||||
/** One terminal input or workspace mutation performed after its marker renders. */
|
||||
type TuiPtyAction =
|
||||
| { readonly waitFor: string; readonly send: string }
|
||||
| {
|
||||
readonly waitFor: string
|
||||
readonly occurrence?: number
|
||||
readonly send: string
|
||||
}
|
||||
| { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' }
|
||||
| {
|
||||
readonly waitFor: string
|
||||
readonly occurrence?: number
|
||||
readonly writeFile: { readonly path: string; readonly content: string }
|
||||
readonly send?: string
|
||||
}
|
||||
@@ -89,6 +101,12 @@ export interface TuiPtySmokeOptions {
|
||||
readonly env?: Readonly<NodeJS.ProcessEnv>
|
||||
readonly expectedExitCode?: number
|
||||
readonly timeoutMs?: number
|
||||
/** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */
|
||||
readonly cwd?: string
|
||||
/** Pseudo-terminal columns; defaults to 100. */
|
||||
readonly columns?: number
|
||||
/** Pseudo-terminal rows; defaults to 30. */
|
||||
readonly rows?: number
|
||||
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
|
||||
readonly prepare?: (cwd: string) => Promise<void>
|
||||
/** Inspect the workspace after a passing run, before the temp dir is removed. */
|
||||
@@ -119,6 +137,8 @@ async function runPosixPtySmoke(
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
String(options.columns ?? 100),
|
||||
String(options.rows ?? 30),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
@@ -150,8 +170,8 @@ async function runWindowsPtySmoke(
|
||||
let timedOut = false
|
||||
const terminal = pty.spawn(launch.command, launch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cols: options.columns ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
cwd,
|
||||
env: definedEnv({
|
||||
...process.env,
|
||||
@@ -159,8 +179,8 @@ async function runWindowsPtySmoke(
|
||||
// Match the POSIX driver: no COLORTERM, so the banner never takes the
|
||||
// truecolor gradient path under a developer's shell.
|
||||
COLORTERM: undefined,
|
||||
COLUMNS: '100',
|
||||
LINES: '30',
|
||||
COLUMNS: String(options.columns ?? 100),
|
||||
LINES: String(options.rows ?? 30),
|
||||
}),
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
@@ -169,9 +189,14 @@ async function runWindowsPtySmoke(
|
||||
}, timeoutMs)
|
||||
terminal.onData((chunk) => {
|
||||
output += chunk
|
||||
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
||||
while (
|
||||
actionIndex < actions.length
|
||||
&& output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1)
|
||||
) {
|
||||
const action = actions[actionIndex]!
|
||||
if ('writeFile' in action) {
|
||||
if ('signal' in action) {
|
||||
terminal.kill(action.signal)
|
||||
} else if ('writeFile' in action) {
|
||||
const target = join(cwd, action.writeFile.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, action.writeFile.content)
|
||||
@@ -205,7 +230,8 @@ async function runWindowsPtySmoke(
|
||||
* @returns complete pseudo-terminal output.
|
||||
*/
|
||||
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const ownedCwd = options.cwd === undefined
|
||||
const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const timeoutMs = options.timeoutMs ?? 25_000
|
||||
try {
|
||||
await options.prepare?.(cwd)
|
||||
@@ -231,6 +257,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
|
||||
await options.inspect?.(cwd)
|
||||
return output
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
if (ownedCwd) await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-run-welcome.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:
|
||||
* the `cordis_*` toolset executes model-written JavaScript that no sandbox row
|
||||
* confines, `web_fetch` chooses its own request target, and `mcp_*` servers
|
||||
* spawn outside `ctx.bash`. 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' },
|
||||
prepare: cwd => acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')),
|
||||
// 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)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
terminal 100x36 buffer=normal length=66 base=30 viewport=30
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Reply with exactly the word: — DSH TUI snapshot"
|
||||
cursor hidden column=7 viewportRow=35 bufferRow=65
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Reply with exactly the word:"
|
||||
style 1-28 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Context · snapshot-seed"
|
||||
style 0-22 dim
|
||||
5| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
6| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
7| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
8| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
9| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
10| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
11| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
12| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
13| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
14| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
15| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
16| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
17| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
18| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
19| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-94 dim
|
||||
20| <blank>
|
||||
21| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
22| "Reply with exactly the word: ONE. No tools. "
|
||||
23| <blank>
|
||||
24| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
25| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
26| "The user wants me to reply with exactly the word \"ONE\" and use no tools. "
|
||||
style 0-71 dim italic
|
||||
27| "ONE "
|
||||
28| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
29| <blank>
|
||||
30| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-magenta bold
|
||||
31| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 dim
|
||||
32| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L "
|
||||
style 0-99 dim
|
||||
33| "redraw "
|
||||
style 0-5 dim
|
||||
34| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 dim
|
||||
35| " "
|
||||
36| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
37| "/compact — Compact older conversation history "
|
||||
style 0-44 dim
|
||||
38| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
39| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
40| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
41| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
42| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
44| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
45| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
46| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
47| <blank>
|
||||
48| "Context · snapshot-injector"
|
||||
style 0-26 dim
|
||||
49| "Injected while compaction was running. "
|
||||
style 0-37 dim
|
||||
50| <blank>
|
||||
51| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
52| <blank>
|
||||
53| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
54| "Reply with exactly the word: TWO. No tools. "
|
||||
55| <blank>
|
||||
56| "Compacted 2 history items (~387 tokens). "
|
||||
style 0-39 dim
|
||||
57| <blank>
|
||||
58| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
59| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
60| "The user wants me to reply with exactly the word \"TWO\" and no tools. "
|
||||
style 0-67 dim italic
|
||||
61| "TWO "
|
||||
62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
63| <blank>
|
||||
64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont"
|
||||
style 0-49 fg=bright-magenta bold
|
||||
style 52-68 dim
|
||||
style 71-90 dim
|
||||
style 93-99 dim
|
||||
65| " dsh ◍ "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 120x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-119 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 75-90 fg=blue bold
|
||||
style 119-119 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 119-119 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 49-70 bold
|
||||
style 119-119 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 119-119 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
10| "│ ▀███ ▀██████████████ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 119-119 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 119-119 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-119 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 54-64 fg=bright-magenta bold
|
||||
style 119-119 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 119-119 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-119 dim
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 160x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-159 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 95-110 fg=blue bold
|
||||
style 159-159 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 49-70 bold
|
||||
style 159-159 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 159-159 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 159-159 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 159-159 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 159-159 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-159 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 74-84 fg=bright-magenta bold
|
||||
style 159-159 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 159-159 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-159 dim
|
||||
@@ -0,0 +1,31 @@
|
||||
overlay 60x12 rows=10
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
7| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
8| "│ ↑/↓ 滚动 ↓ │"
|
||||
style 0-0 dim
|
||||
style 24-35 dim
|
||||
style 59-59 dim
|
||||
9| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,64 @@
|
||||
overlay 60x30 rows=21
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 59-59 dim
|
||||
7| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
10| "│ 设计。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
11| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
16| "│ 它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
17| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
18| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
19| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
20| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,89 @@
|
||||
overlay 80x30 rows=27
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-79 dim
|
||||
1| "│ ▄▄▄▄▄▄ ▄▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
2| "│ ▄████████▄ ▀████▀ │"
|
||||
style 0-0 dim
|
||||
style 31-48 fg=blue
|
||||
style 79-79 dim
|
||||
3| "│ █▀▀▀▀███████▄██▀ │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue
|
||||
style 79-79 dim
|
||||
4| "│ █▄ ▀███ ▀███ │"
|
||||
style 0-0 dim
|
||||
style 32-46 fg=blue
|
||||
style 79-79 dim
|
||||
5| "│ ▀█▄ ▀█████ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
6| "│ ▀█▄▄ █▄▄▀███▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
7| "│ ▀▀▀▀▀▀ │"
|
||||
style 0-0 dim
|
||||
style 35-44 fg=blue
|
||||
style 79-79 dim
|
||||
8| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
9| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue bold
|
||||
style 79-79 dim
|
||||
10| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
12| "│ 善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
13| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
14| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 79-79 dim
|
||||
15| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
22| "│ 好。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
23| "├──────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-79 dim
|
||||
24| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 34-44 fg=bright-magenta bold
|
||||
style 79-79 dim
|
||||
25| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
26| "╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-79 dim
|
||||
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { visibleWidth } from '@earendil-works/pi-tui'
|
||||
import {
|
||||
type TuiOverlayHost,
|
||||
type TuiOverlayRequest,
|
||||
type TuiTheme,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
apply,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
needsTuiFirstRunWelcomeAsciiArt,
|
||||
TuiFirstRunWelcomeComponent,
|
||||
tuiFirstRunWelcomeAcknowledgementPath,
|
||||
tuiFirstRunWelcomeArtTier,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const mockDisposeRootAndExit = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-tui')>(),
|
||||
disposeRootAndExit: mockDisposeRootAndExit,
|
||||
}))
|
||||
|
||||
const identityTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => value,
|
||||
brand: (value: string) => value,
|
||||
dim: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
success: (value: string) => value,
|
||||
warning: (value: string) => value,
|
||||
error: (value: string) => value,
|
||||
bold: (value: string) => value,
|
||||
})
|
||||
|
||||
function hostFixture(rows: number): {
|
||||
host: TuiOverlayHost
|
||||
closed: () => boolean
|
||||
invalidations: () => number
|
||||
} {
|
||||
let closed = false
|
||||
let invalidations = 0
|
||||
const controller = new AbortController()
|
||||
return {
|
||||
host: Object.freeze({
|
||||
signal: controller.signal,
|
||||
viewport: Object.freeze({ columns: 160, rows }),
|
||||
theme: identityTheme,
|
||||
display: (value: string) => value,
|
||||
invalidate: () => { invalidations += 1 },
|
||||
close: () => { closed = true },
|
||||
}),
|
||||
closed: () => closed,
|
||||
invalidations: () => invalidations,
|
||||
}
|
||||
}
|
||||
|
||||
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
function withoutWhitespace(value: string): string {
|
||||
return value.replace(/\s/gu, '')
|
||||
}
|
||||
|
||||
async function temporaryHome(prefix: string): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), prefix))
|
||||
temporaryHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mockDisposeRootAndExit.mockClear()
|
||||
await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome acknowledgement', () => {
|
||||
it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-ack-')
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false)
|
||||
|
||||
await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home)))
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION))
|
||||
expect(info.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('treats a notice-version bump as a new one-time acknowledgement', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-version-')
|
||||
await acknowledgeTuiFirstRunWelcome(home)
|
||||
const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false)
|
||||
await acknowledgeTuiFirstRunWelcome(home, nextVersion)
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed marker instead of silently acknowledging it', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-malformed-')
|
||||
await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), {
|
||||
recursive: true,
|
||||
})
|
||||
await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file')
|
||||
await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('detects only explicit ASCII-only terminal environments', () => {
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false)
|
||||
expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome composition', () => {
|
||||
it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => {
|
||||
const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd()
|
||||
expect(createHash('sha256').update(icon).digest('hex'))
|
||||
.toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439')
|
||||
expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex'))
|
||||
.toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da')
|
||||
expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 60, inner: 50, rows: 30, tier: undefined },
|
||||
{ columns: 80, inner: 68, rows: 30, tier: 'minimal' },
|
||||
{ columns: 100, inner: 84, rows: 34, tier: 'compact' },
|
||||
{ columns: 120, inner: 104, rows: 30, tier: 'full' },
|
||||
{ columns: 160, inner: 140, rows: 30, tier: 'full' },
|
||||
] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const renderWidth = inner + 4
|
||||
const lines = component.render(renderWidth)
|
||||
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true)
|
||||
if (tier === undefined) {
|
||||
expect(lines.join('\n')).not.toMatch(/[▀▄█]/u)
|
||||
} else {
|
||||
expect(lines.join('\n')).toContain(artAnchor(tier))
|
||||
}
|
||||
const rendered = lines.join('\n')
|
||||
const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0]
|
||||
expect(rendered).not.toContain(copy.scrollHint)
|
||||
expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0])
|
||||
expect(rendered).toContain(optOut)
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ inner: 68, rows: 14, tier: undefined },
|
||||
{ inner: 68, rows: 17, tier: undefined },
|
||||
{ inner: 68, rows: 18, tier: 'minimal' },
|
||||
{ inner: 84, rows: 21, tier: 'minimal' },
|
||||
{ inner: 84, rows: 22, tier: 'compact' },
|
||||
] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const lines = component.render(inner + 4)
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
})
|
||||
|
||||
it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => {
|
||||
const fixture = hostFixture(10)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const initial = component.render(54).join('\n')
|
||||
expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined()
|
||||
expect(initial).toContain(openingSentence)
|
||||
expect(initial).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
component.handleInput('\x1b[F')
|
||||
const end = component.render(54).join('\n')
|
||||
expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-7)))
|
||||
expect(end).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) {
|
||||
component.handleInput(key)
|
||||
}
|
||||
component.invalidate()
|
||||
})
|
||||
|
||||
it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => {
|
||||
const fixture = hostFixture(5)
|
||||
const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {})
|
||||
const lines = component.render(2)
|
||||
expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => {
|
||||
const fixture = hostFixture(40)
|
||||
const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {})
|
||||
const lines = component.render(100)
|
||||
expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length)
|
||||
expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true)
|
||||
component.handleInput('\x1b[F')
|
||||
expect(component.render(100).join('\n')).toContain(copy.title)
|
||||
})
|
||||
|
||||
it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => {
|
||||
const fixture = hostFixture(30)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true)
|
||||
const rendered = component.render(72).join('\n')
|
||||
expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
expect(rendered).not.toMatch(/[▀▄█]/u)
|
||||
})
|
||||
|
||||
it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => {
|
||||
const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => {
|
||||
if (cell === '▀') return "'"
|
||||
if (cell === '▄') return '_'
|
||||
if (cell === '█') return '#'
|
||||
return cell
|
||||
}).join(''))
|
||||
expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii)
|
||||
})
|
||||
|
||||
it('ignores Escape and acknowledges only Enter before closing', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\x1b')
|
||||
await Promise.resolve()
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit)
|
||||
component.handleInput('\x03')
|
||||
component.handleInput('\x04')
|
||||
expect(exit).toHaveBeenCalledTimes(2)
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start a second acknowledgement while the first Enter is pending', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const acknowledge = vi.fn(async () => pending.promise)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\x1b[B')
|
||||
expect(component.render(72).join('\n')).toContain(copy.saving)
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
|
||||
pending.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
})
|
||||
|
||||
it('keeps the overlay open after a persistence failure and lets Enter retry', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
let attempts = 0
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('disk unavailable')
|
||||
}, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(component.render(72).join('\n')).toContain(copy.saveError)
|
||||
})
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(attempts).toBe(2)
|
||||
expect(fixture.invalidations()).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-apply-')
|
||||
let request: TuiOverlayRequest | undefined
|
||||
let disposePending: (() => Promise<void>) | undefined
|
||||
const ctx = {
|
||||
effect(register: () => () => Promise<void>) {
|
||||
disposePending = register()
|
||||
return () => {}
|
||||
},
|
||||
tui: {
|
||||
openOverlay(value: TuiOverlayRequest) {
|
||||
request = value
|
||||
return {} as never
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
apply(ctx, { dshHome: home })
|
||||
expect(request?.options).toEqual({
|
||||
width: '100%',
|
||||
maxHeight: '90%',
|
||||
anchor: 'center',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const fixture = hostFixture(30)
|
||||
const component = request?.create(fixture.host)
|
||||
expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent)
|
||||
component?.handleInput?.('\x03')
|
||||
expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0)
|
||||
component?.handleInput?.('\r')
|
||||
await disposePending?.()
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
|
||||
apply(ctx, { dshHome: home, asciiArt: true })
|
||||
expect(request?.create(fixture.host).render(72).join('\n'))
|
||||
.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -8,12 +9,30 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
|
||||
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
|
||||
import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
// `--config` layers an overlay over the shared base, so the default surface
|
||||
// needs no config argument at all; these are the overlays under test.
|
||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url))
|
||||
const synchronizedFrameEnd = '\x1b[?2026l'
|
||||
// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a
|
||||
// five-second backstop. Keep Vitest outside both deadlines so the harness can
|
||||
// report its own marker, exit, and cleanup failure instead of being cut off.
|
||||
const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
|
||||
? 75_000
|
||||
: LOADER_SMOKE_TEST_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
|
||||
@@ -125,20 +144,77 @@ async function readLoggedRequestContext(cwd: string): Promise<LoggedRequestConte
|
||||
* `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that
|
||||
* same base through `--config`).
|
||||
*/
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & {
|
||||
label: string
|
||||
showFirstRunWelcome?: boolean
|
||||
}): Promise<string> {
|
||||
const { showFirstRunWelcome = false, prepare, ...options } = overrides
|
||||
return runTuiPtySmoke({
|
||||
tempDirPrefix: 'dsh-tui-smoke-',
|
||||
binScript: dshBinScript,
|
||||
tsconfigPath,
|
||||
// Telemetry now mounts in the shared base: keep fixture sessions from
|
||||
// POSTing to the production endpoint when run outside CI's workflow env.
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
LC_CTYPE: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
...overrides,
|
||||
...options,
|
||||
prepare: async (cwd) => {
|
||||
if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh'))
|
||||
await prepare?.(cwd)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
|
||||
function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */
|
||||
function overlaySnapshot(snapshot: string, columns: number, rows: number): string {
|
||||
const blocks: string[][] = []
|
||||
for (const line of snapshot.split('\n')) {
|
||||
if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line])
|
||||
else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line)
|
||||
}
|
||||
const first = blocks.findIndex(block => block[0]?.includes('╭') === true)
|
||||
const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true)
|
||||
if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame')
|
||||
const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [
|
||||
block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`),
|
||||
...block.slice(1),
|
||||
])
|
||||
return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n')
|
||||
}
|
||||
|
||||
/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */
|
||||
async function firstRunFrameSnapshot(
|
||||
output: string,
|
||||
marker: string,
|
||||
columns: number,
|
||||
rows: number,
|
||||
): Promise<string> {
|
||||
const markerIndex = output.indexOf(marker)
|
||||
if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`)
|
||||
const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex)
|
||||
if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`)
|
||||
const terminal = new HeadlessTerminal(columns, rows)
|
||||
try {
|
||||
terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length))
|
||||
return overlaySnapshot(await terminal.snapshot(), columns, rows)
|
||||
} finally {
|
||||
await terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// The scripted conversation switches to the pro model first: the scripted
|
||||
// adapter proves routing + prompt variables by rejecting tool-ful calls on any
|
||||
// other route (see fixtures/tui-scripted-llm.ts).
|
||||
@@ -148,6 +224,138 @@ const SELECT_PRO_MODEL = [
|
||||
] as const
|
||||
|
||||
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
it.each([
|
||||
{ columns: 60, tier: undefined },
|
||||
{ columns: 80, tier: 'minimal' },
|
||||
{ columns: 120, tier: 'full' },
|
||||
{ columns: 160, tier: 'full' },
|
||||
] as const)('renders and acknowledges the responsive first-run composition at $columns columns', async ({ columns, tier }) => {
|
||||
const output = await smoke({
|
||||
label: `dsh first-run welcome ${String(columns)} columns`,
|
||||
tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
columns,
|
||||
rows: 30,
|
||||
actions: [
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true)
|
||||
const entries = await readdir(join(cwd, '.sessions'), { recursive: true })
|
||||
const logs = entries.filter(name => name.endsWith('.jsonl'))
|
||||
for (const log of logs) {
|
||||
const stored = await readFile(join(cwd, '.sessions', log), 'utf8')
|
||||
expect(stored).not.toContain(firstRunCopy.paragraphs[0])
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`))
|
||||
if (tier === undefined) {
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
} else {
|
||||
expect(output).toContain(firstRunArtAnchor(tier))
|
||||
}
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh low-height first-run welcome',
|
||||
tempDirPrefix: 'dsh-tui-welcome-low-',
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
columns: 60,
|
||||
rows: 12,
|
||||
actions: [
|
||||
{ waitFor: firstRunOpeningSentence, send: '\x1b[F' },
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
occurrence: 2,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt'))
|
||||
expect(output).toContain(firstRunCopy.title)
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('企业微信群')
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('shows once and skips the second launch under the same DSH_HOME', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-'))
|
||||
try {
|
||||
const first = await smoke({
|
||||
label: 'dsh first welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(first).toContain(firstRunCopy.title)
|
||||
|
||||
const second = await smoke({
|
||||
label: 'dsh second welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: process.platform === 'win32' ? 0 : -15,
|
||||
actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }],
|
||||
})
|
||||
expect(second).not.toContain(firstRunOpeningSentence)
|
||||
expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-'))
|
||||
try {
|
||||
await smoke({
|
||||
label: 'dsh aborted welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: -15,
|
||||
actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }],
|
||||
inspect: async (workspace) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
const next = await smoke({
|
||||
label: 'dsh welcome after aborted launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(next).toContain(firstRunOpeningSentence)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
|
||||
// With no configured welcome the borderless banner sweeps in left-to-right;
|
||||
// the detail line's session id (`main-session-<uuid>`) renders only once
|
||||
@@ -170,7 +378,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
|
||||
const output = await smoke({
|
||||
@@ -223,7 +431,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('Registered tools')
|
||||
expect(output).toContain('ask_user_question')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
|
||||
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
|
||||
@@ -259,7 +467,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('[instructions]')
|
||||
expect(output).toContain('Scripted skill body received.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
|
||||
const skill = [
|
||||
@@ -289,7 +497,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => {
|
||||
const output = await smoke({
|
||||
@@ -314,11 +522,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('File · terminal-special-case.t')
|
||||
expect(output).toContain('@src/terminal-special-case.ts')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
})
|
||||
|
||||
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
it('shows the terminal-local notice over a resumed session without changing its log', async () => {
|
||||
let originalLineCount = 0
|
||||
const output = await smoke({
|
||||
label: 'dsh first-run notice on resume',
|
||||
tempDirPrefix: 'dsh-tui-welcome-resume-',
|
||||
binScript: dshBinScript,
|
||||
configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath],
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
prepare: async (cwd) => {
|
||||
await seedResumeSession(cwd)
|
||||
const before = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
originalLineCount = before.split('\n').filter(Boolean).length
|
||||
},
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
const after = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
expect(after).not.toContain(firstRunCopy.paragraphs[0])
|
||||
const appended = after.split('\n').filter(Boolean).slice(originalLineCount)
|
||||
.map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' }))
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' }))
|
||||
},
|
||||
})
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('Resume selector design — DeepSeek Harness')
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh in-place resume',
|
||||
@@ -338,7 +586,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(released).toBeGreaterThanOrEqual(0)
|
||||
expect(restored).toBeGreaterThan(released)
|
||||
expect(output).toContain('Preserve restored state')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots the shipped default config with no arguments and no personal overlay', async () => {
|
||||
const output = await smoke({
|
||||
@@ -353,7 +601,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
|
||||
// The whole personal-config chain in one boot, plus the environment layer
|
||||
@@ -390,7 +638,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('PROJECT OVERLAY READY.')
|
||||
expect(output).not.toContain('HOME ENV LEAKED.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
|
||||
const output = await smoke({
|
||||
@@ -402,7 +650,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('must be a top-level YAML array of loader patch entries')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
|
||||
// The flag path end to end: apps/cli parses `--resume missing-session`,
|
||||
@@ -417,7 +665,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
|
||||
// The exit line is built by apps/cli from this invocation, so it must carry
|
||||
@@ -430,7 +678,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
|
||||
// Loader patches replace a targeted `config` key wholesale, so a personal
|
||||
@@ -465,7 +713,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
|
||||
// The model-facing result ends in `[exit code: 3]`, which the terminal card
|
||||
@@ -488,7 +736,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('SCRIPTED_BASH_FAILED')
|
||||
expect(output).toContain('[exit 3]')
|
||||
expect(output).not.toContain('[exit code: 3]')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('tells the model its source path and offers the bundled maintenance skills', async () => {
|
||||
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
|
||||
@@ -514,5 +762,5 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
|
||||
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
|
||||
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -11,8 +11,12 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import * as CommandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
@@ -45,6 +49,8 @@ type ScenarioInteraction = 'skill-invocation-policy'
|
||||
|
||||
interface Scenario {
|
||||
name: string
|
||||
/** Replay fixture owned by an earlier scenario, for a derived presentation case. */
|
||||
fixture?: string
|
||||
composition: Composition
|
||||
expectedTools: string[]
|
||||
expectedEventCounts?: Record<string, number>
|
||||
@@ -68,6 +74,13 @@ interface Scenario {
|
||||
spillMaxInlineBytes?: number
|
||||
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
|
||||
interaction?: ScenarioInteraction
|
||||
/**
|
||||
* Mount a deterministic compaction backend plus `/compact`, then run the
|
||||
* human command with a held summary while a prompt and injected context
|
||||
* arrive. Proves queued input waits for the standalone bracket's durability
|
||||
* checkpoint instead of racing the replacement.
|
||||
*/
|
||||
manualCompact?: boolean
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
@@ -80,6 +93,14 @@ const SCENARIOS: Scenario[] = [
|
||||
leavePlanModeAfterFirstTurn: true,
|
||||
recorded: true,
|
||||
},
|
||||
{
|
||||
name: 'queued-manual-compact',
|
||||
fixture: 'multi-turn-conversation',
|
||||
composition: 'native',
|
||||
expectedTools: [],
|
||||
recorded: false,
|
||||
manualCompact: true,
|
||||
},
|
||||
{
|
||||
name: 'todo-plan',
|
||||
composition: 'native',
|
||||
@@ -149,6 +170,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
|
||||
|
||||
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
|
||||
const observedScenarios = new Set<string>()
|
||||
const workerState = Reflect.get(globalThis, '__vitest_worker__') as
|
||||
| { readonly config?: { readonly testNamePattern?: RegExp } }
|
||||
| undefined
|
||||
// Worker argv omits the parent CLI's `-t`; the serialized runner config is the
|
||||
// authoritative distinction between a focused replay and the full suite.
|
||||
const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined
|
||||
|
||||
/**
|
||||
* Deterministic keyless summary that pauses so the scenario can submit a real
|
||||
* prompt and inject context while manual compaction holds turn admission.
|
||||
*/
|
||||
class DeferredSnapshotCompactService extends BasicCompactService {
|
||||
readonly summaryStarted = Promise.withResolvers<undefined>()
|
||||
readonly releaseSummary = Promise.withResolvers<undefined>()
|
||||
|
||||
override async summarize(
|
||||
_input: SummarizationInput,
|
||||
_agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> {
|
||||
this.summaryStarted.resolve(undefined)
|
||||
await this.releaseSummary.promise
|
||||
signal?.throwIfAborted()
|
||||
return {
|
||||
summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }],
|
||||
provider: 'snapshot',
|
||||
model: 'snapshot-compactor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed between-turn model-visible history without inventing a loop execution. */
|
||||
function seedCompactableHistory(agent: Agent): void {
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }],
|
||||
source: { kind: 'plugin', plugin: 'snapshot-seed' },
|
||||
}))
|
||||
}
|
||||
|
||||
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
|
||||
const rel = relative(cwd, displayPath)
|
||||
@@ -161,10 +220,15 @@ function scenarioDir(scenario: Scenario): string {
|
||||
return join(SNAPSHOTS_DIR, scenario.name)
|
||||
}
|
||||
|
||||
/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */
|
||||
function fixtureDir(scenario: Scenario): string {
|
||||
return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name)
|
||||
}
|
||||
|
||||
function childFixturePaths(scenario: Scenario): string[] {
|
||||
return Array.from(
|
||||
{ length: scenario.childSessions ?? 0 },
|
||||
(_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
|
||||
(_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +270,24 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
|
||||
if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
|
||||
}
|
||||
|
||||
/** Bound deterministic in-process coordination waits with actionable state. */
|
||||
async function snapshotDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
detail: () => string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => { reject(new Error(detail())) }, 5_000)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function mountScenarioContext(
|
||||
scenario: Scenario,
|
||||
cwd: string,
|
||||
@@ -232,6 +314,9 @@ async function mountScenarioContext(
|
||||
skills: { local: { agentsHome: join(cwd, '.agents') } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService)
|
||||
if (scenario.manualCompact === true) {
|
||||
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
|
||||
}
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
|
||||
@@ -249,6 +334,7 @@ async function mountScenarioContext(
|
||||
await ctx.plugin(ToolWorkflow)
|
||||
await ctx.plugin(ToolRalph)
|
||||
await ctx.plugin(CommandService)
|
||||
if (scenario.manualCompact === true) await ctx.plugin(CommandCompact)
|
||||
if (scenario.enterPlanMode === true) {
|
||||
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
|
||||
}
|
||||
@@ -276,9 +362,9 @@ interface ScenarioResult {
|
||||
}
|
||||
|
||||
async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
|
||||
const dir = scenarioDir(scenario)
|
||||
const fixtureFile = join(dir, 'session.jsonl')
|
||||
const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime()
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime)
|
||||
const fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
|
||||
const childFiles = childFixturePaths(scenario)
|
||||
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
|
||||
if (scenario.interaction === undefined) {
|
||||
@@ -292,7 +378,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const terminal = new HeadlessTerminal(100, 36)
|
||||
try {
|
||||
if (scenario.seedWorkspace === true) {
|
||||
const source = join(scenarioDir(scenario), 'workspace')
|
||||
const source = join(fixtureDir(scenario), 'workspace')
|
||||
await cp(source, cwd, { recursive: true })
|
||||
}
|
||||
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
|
||||
@@ -308,6 +394,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
const agent: Agent = handle.agent
|
||||
if (scenario.manualCompact === true) seedCompactableHistory(agent)
|
||||
controller = createTuiChat(ctx, {
|
||||
sessionId: 'main-session',
|
||||
theme: { color: true },
|
||||
@@ -380,6 +467,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
|
||||
let remainingPrompts = prompts
|
||||
let queuedPrompt: string | undefined
|
||||
let manualOrder: string[] | undefined
|
||||
let manualCommandId: string | undefined
|
||||
if (scenario.manualCompact === true) {
|
||||
expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2)
|
||||
queuedPrompt = prompts.at(-1)
|
||||
remainingPrompts = prompts.slice(0, -1)
|
||||
}
|
||||
if (scenario.enterPlanMode === true) {
|
||||
const firstPrompt = prompts[0]!
|
||||
terminal.send(`/plan ${firstPrompt}`)
|
||||
@@ -396,12 +491,93 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
|
||||
for (const prompt of remainingPrompts) {
|
||||
const admitted = agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user').length
|
||||
terminal.send(prompt)
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await expect.poll(() => agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1)
|
||||
await agent.whenIdle()
|
||||
await settleTerminal(terminal)
|
||||
}
|
||||
|
||||
if (scenario.manualCompact === true && queuedPrompt !== undefined) {
|
||||
terminal.send('/help')
|
||||
terminal.send('\r')
|
||||
await settleTerminal(terminal)
|
||||
expect(await terminal.snapshot({ includeScrollback: true }))
|
||||
.toContain('/compact — Compact older conversation history')
|
||||
|
||||
const compact = ctx.compact as DeferredSnapshotCompactService
|
||||
const inbox: string[] = []
|
||||
manualOrder = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
|
||||
})
|
||||
ctx.on('agent/inbox/dequeue', (subject, message) => {
|
||||
if (subject === agent) inbox.push(`dequeue:${message.id}`)
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'command/run' && event.data.name === 'compact') {
|
||||
manualCommandId = event.data.commandId
|
||||
manualOrder?.push('command/run')
|
||||
}
|
||||
if (event.type === 'command/done' && event.data.commandId === manualCommandId) {
|
||||
manualOrder?.push('command/done')
|
||||
}
|
||||
if (event.type.startsWith('compact/')) manualOrder?.push(event.type)
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
|
||||
if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
|
||||
})
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session === agent.session) manualOrder?.push('flush')
|
||||
})
|
||||
|
||||
terminal.send('/compact')
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await snapshotDeadline(compact.summaryStarted.promise, () =>
|
||||
`manual summary did not start; status=${agent.status}; tail=${
|
||||
agent.session.events.slice(-8).map(event => event.type).join(',')
|
||||
}`)
|
||||
clock.mockReturnValue(snapshotTime + 1_000)
|
||||
await settleTerminal(terminal)
|
||||
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
|
||||
await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s')
|
||||
const liveCompaction = await terminal.snapshot()
|
||||
expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙'))
|
||||
clock.mockReturnValue(snapshotTime)
|
||||
|
||||
// Real keystrokes: the prompt keeps its ordinary queue identity while
|
||||
// admission is reserved, and an injection appends immediately.
|
||||
terminal.send(queuedPrompt)
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await expect.poll(() => inbox.length).toBe(1)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
|
||||
source: { kind: 'plugin', plugin: 'snapshot-injector' },
|
||||
}))
|
||||
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false)
|
||||
|
||||
const idle = agent.whenIdle()
|
||||
compact.releaseSummary.resolve(undefined)
|
||||
await snapshotDeadline(idle, () =>
|
||||
`manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${
|
||||
agent.session.events.slice(-12).map(event => event.type).join(',')
|
||||
}`)
|
||||
await settleTerminal(terminal)
|
||||
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
|
||||
}
|
||||
|
||||
const events: SessionEvent[] = [...agent.session.events]
|
||||
const firstHeader = events.find(event => event.type === 'request/header')
|
||||
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
|
||||
@@ -437,6 +613,87 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
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.' }])
|
||||
}
|
||||
if (scenario.manualCompact === true) {
|
||||
const compactStart = events.find(event => event.type === 'compact/start')
|
||||
const compactSummary = events.find(event => event.type === 'compact/summary')
|
||||
const compactCheckpoint = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact')
|
||||
const injectedEvent = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector')
|
||||
const compactEnd = events.find(event => event.type === 'compact/end')
|
||||
expect(compactStart?.data.turn).toBeNull()
|
||||
expect(compactEnd?.data.turn).toBeNull()
|
||||
expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1)
|
||||
if (compactStart === undefined || compactSummary === undefined
|
||||
|| compactCheckpoint === undefined || injectedEvent === undefined
|
||||
|| compactEnd === undefined) {
|
||||
throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection')
|
||||
}
|
||||
// The markers are time points, not an exclusive container: unrelated
|
||||
// idle injection is allowed between them while the selected span stays stable.
|
||||
expect(compactStart.seq).toBeLessThan(injectedEvent.seq)
|
||||
expect(injectedEvent.seq).toBeLessThan(compactSummary.seq)
|
||||
expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq)
|
||||
expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq)
|
||||
|
||||
const manualTimeline = manualOrder ?? []
|
||||
const commandRunIndex = manualTimeline.indexOf('command/run')
|
||||
const compactStartIndex = manualTimeline.indexOf('compact/start')
|
||||
const compactEndIndex = manualTimeline.indexOf('compact/end')
|
||||
const firstFlushIndex = manualTimeline.indexOf('flush')
|
||||
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
|
||||
const commandDoneIndex = manualTimeline.indexOf('command/done')
|
||||
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
|
||||
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
|
||||
expect(compactStartIndex).toBeGreaterThan(commandRunIndex)
|
||||
expect(compactEndIndex).toBeGreaterThan(compactStartIndex)
|
||||
expect(firstFlushIndex).toBeGreaterThan(compactEndIndex)
|
||||
expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex)
|
||||
expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex)
|
||||
|
||||
const commandRun = events.find(event => event.type === 'command/run'
|
||||
&& event.data.name === 'compact')
|
||||
const commandRunId = commandRun?.type === 'command/run'
|
||||
? commandRun.data.commandId
|
||||
: undefined
|
||||
const commandDone = events.find(event => event.type === 'command/done'
|
||||
&& event.data.commandId === commandRunId)
|
||||
expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({
|
||||
commandId: commandRunId,
|
||||
name: 'compact',
|
||||
args: '',
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({
|
||||
commandId: commandRunId,
|
||||
kind: 'success',
|
||||
text: 'Compacted 2 history items (~387 tokens).',
|
||||
})
|
||||
expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true)
|
||||
expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true)
|
||||
expect(agent.session.surface.nodes).not.toContain(commandRun?.seq)
|
||||
expect(agent.session.surface.nodes).not.toContain(commandDone?.seq)
|
||||
|
||||
// The command line itself never becomes a prompt.
|
||||
expect(events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false)
|
||||
const derived = agent.session.deriveMessages().map(message => message.content
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join(''))
|
||||
const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.'))
|
||||
const injected = derived.findIndex(text => text.includes('Injected while compaction was running.'))
|
||||
const queued = derived.findIndex(text => text === queuedPrompt)
|
||||
expect(checkpoint).toBe(0)
|
||||
expect(injected).toBeGreaterThan(checkpoint)
|
||||
expect(queued).toBeGreaterThan(injected)
|
||||
expect(derived).not.toContain('/compact')
|
||||
expect(derived).not.toContain('Compacted 2 history items (~387 tokens).')
|
||||
expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1)
|
||||
expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq)
|
||||
const queuedTurn = events.findLast(event => event.type === 'turn/start')
|
||||
expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true)
|
||||
}
|
||||
if (scenario.spillMaxInlineBytes !== undefined) {
|
||||
// The REAL pipeline ran (tools execute on replay too): the durable
|
||||
// dispatch copy is bounded to a preview + locator under the run cwd,
|
||||
@@ -514,7 +771,23 @@ describe('TUI recorded-session terminal snapshots', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
|
||||
const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort()
|
||||
const observedNames = [...observedScenarios].sort()
|
||||
if (TEST_NAME_FILTERED) {
|
||||
expect(observedNames).not.toHaveLength(0)
|
||||
expect(scenarioNames).toEqual(expect.arrayContaining(observedNames))
|
||||
} else {
|
||||
expect(observedNames).toEqual(scenarioNames)
|
||||
}
|
||||
for (const [index, scenario] of SCENARIOS.entries()) {
|
||||
if (scenario.fixture === undefined) continue
|
||||
const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture)
|
||||
expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0)
|
||||
expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index)
|
||||
const source = SCENARIOS[sourceIndex]
|
||||
expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined()
|
||||
expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true)
|
||||
}
|
||||
const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
@@ -522,14 +795,14 @@ afterAll(async () => {
|
||||
expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
|
||||
for (const scenario of SCENARIOS) {
|
||||
const expected = [
|
||||
'session.jsonl',
|
||||
...scenario.fixture === undefined ? ['session.jsonl'] : [],
|
||||
'terminal.expected.txt',
|
||||
...scenario.seedWorkspace === true ? ['workspace'] : [],
|
||||
...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [],
|
||||
...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
|
||||
].sort()
|
||||
expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
|
||||
for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
|
||||
const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
|
||||
const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8')
|
||||
expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user