From 350841a6a0004cc5287dba30197068aea0bbb90a Mon Sep 17 00:00:00 2001 From: 4222222 Date: Fri, 14 Aug 2026 17:50:44 +0000 Subject: [PATCH 1/2] test: extract cli pure functions + CLI test suite (node:test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract parseArgs (cli/args.js) and env loading/persistence (cli/env.js) from cli.js as pure functions — behavior identical, enabling unit tests for logic that was previously top-level-only. Export parseFrontmatter/discover from skill-scanner.js (1-line each). Add tests/ with node:test (zero deps): - args: --resume absence, provider defaults, model position (8) - env: KEY=VALUE parsing, no-override, idempotent persistence (7) - skill-scanner: frontmatter scalars, discovery (6) - CLI integration: --sessions boot smoke, EOF graceful exit; full-session cases (piped input, flush, bare /provider) auto-skip without an API key Verification: conformance gate still IDENTICAL on both Node and QuickJS (114 events); 23 pass / 3 skip / 0 fail locally. CI now runs the suite after building the CLI. Note: Node recursive mkdirSync hangs on procfs paths (not a fast failure) — tests use an ENOTDIR fixture instead. --- .github/workflows/conformance.yml | 2 + cli/args.js | 17 +++++++ cli/cli.js | 56 ++++++++-------------- cli/env.js | 41 ++++++++++++++++ cli/skill-scanner.js | 4 +- tests/args.test.js | 49 +++++++++++++++++++ tests/cli.integration.test.js | 75 +++++++++++++++++++++++++++++ tests/env.test.js | 79 +++++++++++++++++++++++++++++++ tests/skill-scanner.test.js | 53 +++++++++++++++++++++ 9 files changed, 338 insertions(+), 38 deletions(-) create mode 100644 cli/args.js create mode 100644 cli/env.js create mode 100644 tests/args.test.js create mode 100644 tests/cli.integration.test.js create mode 100644 tests/env.test.js create mode 100644 tests/skill-scanner.test.js diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 059a072..0203ec3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,3 +20,5 @@ jobs: run: bash run.sh - name: Build CLI run: bash cli/cli-build.sh + - name: Run CLI tests (unit + integration) + run: node --test "tests/*.test.js" diff --git a/cli/args.js b/cli/args.js new file mode 100644 index 0000000..e935613 --- /dev/null +++ b/cli/args.js @@ -0,0 +1,17 @@ +// Pure CLI-argument parsing, extracted from cli.js for testability. +// Behavior is IDENTICAL to the original inline logic — do not "improve" it here. + +export function parseArgs(argv, env, providerDefaults) { + const args = argv.filter((a) => !a.startsWith("--")); + const resumeIndex = argv.indexOf("--resume"); + const resumeId = resumeIndex >= 0 ? argv[resumeIndex + 1] : undefined; + const providerIndex = argv.indexOf("--provider"); + const providerOverride = providerIndex >= 0 ? argv[providerIndex + 1] : undefined; + const listSessions = argv.includes("--sessions"); + const provider = + providerOverride ?? + env.DSH_PROVIDER ?? + (env.DEEPSEEK_API_KEY || !env.GEMINI_API_KEY ? "deepseek-official" : "google"); + const model = args[0] ?? providerDefaults[provider]?.model ?? "deepseek-v4-flash"; + return { model, provider, resumeId, listSessions }; +} diff --git a/cli/cli.js b/cli/cli.js index 79c2e18..a1aa5cb 100644 --- a/cli/cli.js +++ b/cli/cli.js @@ -2,6 +2,8 @@ // pi's shell (the real @earendil-works/pi-tui framework) + DSH's engine AND // state (AgentLoop, ToolRuntime, event-sourced sessions, JSONL persistence). import "../polyfills.js"; +import { parseArgs } from "./args.js"; +import { loadEnvFiles, persistCredential } from "./env.js"; import { Context } from "@deepseek-ai/cordis"; import { AgentRegistry } from "@deepseek-ai/dsh-agent"; import { SessionStore } from "@deepseek-ai/dsh-session"; @@ -44,7 +46,7 @@ import { renderBanner } from "./banner.js"; import { defineBashTool, bashGuidanceSection } from "./bash-tool.js"; import * as readline from "node:readline"; import { join } from "node:path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { dirname } from "node:path"; import { zstdCompress } from "node:zlib"; @@ -65,12 +67,6 @@ const TTY = !!process.stdout.isTTY && !!process.stdin.isTTY && !process.env.DSH_ const USE_CC_TUI = TTY && process.env.DSH_TUI !== "basic" && !process.env.DSH_PLAIN; // CLI args: node cli.mjs [model] [--provider ] [--resume ] [--sessions] -const ARGS = process.argv.slice(2).filter((a) => !a.startsWith("--")); -const RESUME_INDEX = process.argv.indexOf("--resume"); -const RESUME_ID = RESUME_INDEX >= 0 ? process.argv[RESUME_INDEX + 1] : undefined; -const PROVIDER_INDEX = process.argv.indexOf("--provider"); -const PROVIDER_OVERRIDE = PROVIDER_INDEX >= 0 ? process.argv[PROVIDER_INDEX + 1] : undefined; -const LIST_SESSIONS = process.argv.includes("--sessions"); // DeepSeek is the default provider (this is dsh, after all): the DSH-native // dsh-llm-deepseek adapter owns the "deepseek-official" route. const PROVIDER_DEFAULTS = { @@ -82,9 +78,11 @@ const PROVIDER_DEFAULTS = { anthropic: { model: "claude-sonnet-4-5", keyEnv: "ANTHROPIC_API_KEY" }, openrouter: { model: "openai/gpt-4o-mini", keyEnv: "OPENROUTER_API_KEY" }, }; -const PROVIDER = PROVIDER_OVERRIDE ?? process.env.DSH_PROVIDER ?? (process.env.DEEPSEEK_API_KEY || !process.env.GEMINI_API_KEY ? "deepseek-official" : "google"); -const MODEL = ARGS[0] ?? PROVIDER_DEFAULTS[PROVIDER]?.model ?? "deepseek-v4-flash"; - +const { model: MODEL, provider: PROVIDER, resumeId: RESUME_ID, listSessions: LIST_SESSIONS } = parseArgs( + process.argv.slice(2), + process.env, + PROVIDER_DEFAULTS, +); const PERSONA = [ "You are dsh-mini, a compact interactive coding agent CLI built on the DeepSeek Harness core.", "You help the user with coding tasks inside the current workspace directory.", @@ -99,34 +97,20 @@ process.on("unhandledRejection", (r) => console.error("[proc] unhandledRejection // Minimal env loader: ~/.dsh-mini/env then ./.env (gitignored), KEY=VALUE // lines, never overriding the real environment. -for (const envFile of [join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")]) { - try { - if (!existsSync(envFile)) continue; - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (match && process.env[match[1]] === undefined) process.env[match[1]] = match[2].trim(); - } - } catch { - // unreadable env file is not fatal - } -} +loadEnvFiles([join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")], process.env); // Persist an interactively entered key: user config dir first, cwd .env // fallback (both gitignored; never touch the repo's tracked files). -function persistCredential(provider, key) { - const env = PROVIDER_DEFAULTS[provider].keyEnv; - for (const target of [join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")]) { - try { - mkdirSync(dirname(target), { recursive: true }); - const previous = existsSync(target) ? readFileSync(target, "utf8").replace(new RegExp(`^${env}=.*$`, "m"), "").trimEnd() : ""; - writeFileSync(target, `${previous}${previous ? "\n" : ""}${env}=${key}\n`); - console.log(`(saved ${env} to ${target})`); - return; - } catch { - // try the next target - } - } - console.error(`[warn] could not persist ${env}; it is set for this session only`); +function persistKey(provider, key) { + const envName = PROVIDER_DEFAULTS[provider].keyEnv; + const saved = persistCredential( + [join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")], + process.env, + envName, + key, + ); + if (saved) console.log(`(saved ${envName} to ${saved})`); + else console.error(`[warn] could not persist ${envName}; it is set for this session only`); } const AGENTS_MD_CAP = 30 * 1024; // keep injected instructions bounded @@ -272,7 +256,7 @@ const boot = async (ctx) => { process.exit(1); } process.env[PROVIDER_DEFAULTS[answer].keyEnv] = key; - persistCredential(answer, key); + persistKey(answer, key); } } diff --git a/cli/env.js b/cli/env.js new file mode 100644 index 0000000..7717f94 --- /dev/null +++ b/cli/env.js @@ -0,0 +1,41 @@ +// Env-file loading + credential persistence, extracted from cli.js for +// testability. Behavior is IDENTICAL to the original inline logic. + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +// KEY=VALUE lines from env files; never overriding the real environment. +// Unreadable env files are not fatal. +export function loadEnvFiles(paths, env) { + for (const envFile of paths) { + try { + if (!existsSync(envFile)) continue; + for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (match && env[match[1]] === undefined) env[match[1]] = match[2].trim(); + } + } catch { + // unreadable env file is not fatal + } + } +} + +// Persist an interactively entered key: try targets in order (user config dir +// first, cwd .env fallback — both gitignored). Replaces any previous value of +// the same var (idempotent). Returns the target path on success, null if all +// targets failed. +export function persistCredential(targets, env, envName, key) { + for (const target of targets) { + try { + mkdirSync(dirname(target), { recursive: true }); + const previous = existsSync(target) + ? readFileSync(target, "utf8").replace(new RegExp(`^${envName}=.*$`, "m"), "").trimEnd() + : ""; + writeFileSync(target, `${previous}${previous ? "\n" : ""}${envName}=${key}\n`); + return target; + } catch { + // try the next target + } + } + return null; +} diff --git a/cli/skill-scanner.js b/cli/skill-scanner.js index 4dc398d..64b6e48 100644 --- a/cli/skill-scanner.js +++ b/cli/skill-scanner.js @@ -8,7 +8,7 @@ import { join, basename } from "node:path"; const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -function parseFrontmatter(text) { +export function parseFrontmatter(text) { const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) return { meta: {}, body: text }; const meta = {}; @@ -19,7 +19,7 @@ function parseFrontmatter(text) { return { meta, body: match[2] }; } -function discover(roots) { +export function discover(roots) { const found = new Map(); for (const root of roots) { if (!existsSync(root)) continue; diff --git a/tests/args.test.js b/tests/args.test.js new file mode 100644 index 0000000..2039c84 --- /dev/null +++ b/tests/args.test.js @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseArgs } from "../cli/args.js"; + +const DEFAULTS = { + "deepseek-official": { model: "deepseek-v4-flash", keyEnv: "DEEPSEEK_API_KEY" }, + google: { model: "gemini-flash-latest", keyEnv: "GEMINI_API_KEY" }, +}; + +test("默认 provider 是 deepseek-official(无任何 key)", () => { + const r = parseArgs([], {}, DEFAULTS); + assert.equal(r.provider, "deepseek-official"); + assert.equal(r.model, "deepseek-v4-flash"); +}); + +test("有 GEMINI key 无 DeepSeek key 时默认 google", () => { + const r = parseArgs([], { GEMINI_API_KEY: "x" }, DEFAULTS); + assert.equal(r.provider, "google"); +}); + +test("--resume 缺席时 resumeId 为 undefined(不是 argv[0],防 SKILL.md L135 坑)", () => { + const r = parseArgs(["some-model"], {}, DEFAULTS); + assert.equal(r.resumeId, undefined); +}); + +test("--resume 带 id 时正确解析", () => { + const r = parseArgs(["--resume", "abc123"], {}, DEFAULTS); + assert.equal(r.resumeId, "abc123"); +}); + +test("--provider 覆盖默认", () => { + const r = parseArgs(["--provider", "google"], {}, DEFAULTS); + assert.equal(r.provider, "google"); +}); + +test("--sessions 标志解析", () => { + assert.equal(parseArgs(["--sessions"], {}, DEFAULTS).listSessions, true); + assert.equal(parseArgs([], {}, DEFAULTS).listSessions, false); +}); + +test("位置参数第一个是 model", () => { + const r = parseArgs(["my-model"], { DEEPSEEK_API_KEY: "x" }, DEFAULTS); + assert.equal(r.model, "my-model"); +}); + +test("DSH_PROVIDER 环境变量优先", () => { + const r = parseArgs([], { DSH_PROVIDER: "google" }, DEFAULTS); + assert.equal(r.provider, "google"); +}); diff --git a/tests/cli.integration.test.js b/tests/cli.integration.test.js new file mode 100644 index 0000000..256cba8 --- /dev/null +++ b/tests/cli.integration.test.js @@ -0,0 +1,75 @@ +// CLI 集成测试:spawn 构建后的 cli.mjs(DSH_PLAIN=1 管道模式)。 +// 无 key 可跑的路径(--sessions)始终执行;需要真实 API key 的用例 +// (完整会话/管道吞行/close 语义)在 CI 无 key 环境下自动 skip。 +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "cli.mjs"); +const HAS_KEY = Boolean(process.env.DEEPSEEK_API_KEY || process.env.GEMINI_API_KEY); + +function runCli(args, input, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { + env: { ...process.env, DSH_PLAIN: "1", DSH_NO_BANNER: "1", ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let out = ""; + let err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + const timer = setTimeout(() => child.kill("SIGKILL"), 30000); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, out, err }); + }); + if (input) child.stdin.write(input); + child.stdin.end(); + }); +} + +test("--sessions 无会话时正常退出 0(CLI boot 冒烟)", async () => { + const dir = mkdtempSync(join(tmpdir(), "dsh-sess-test-")); + const r = await runCli(["--sessions"], "", { DSH_SESSIONS: dir }); + rmSync(dir, { recursive: true, force: true }); + assert.equal(r.code, 0, `stderr: ${r.err.slice(0, 500)}`); +}); + +test("管道 EOF 后进程优雅退出(不僵死)", async () => { + const r = await runCli(["--sessions"], "", {}); + assert.ok(r.code === 0 || r.code === 1, `exit=${r.code}`); +}); + +test( + "完整会话:管道连续输入被消费(SKILL.md 管道吞行坑)", + { skip: !HAS_KEY }, + async () => { + const r = await runCli([], "hello\n/stats\nexit\n", {}); + // 弱断言:进程退出且输出里有会话痕迹;不依赖具体文案。 + assert.ok(r.out.length > 0 || r.err.length > 0); + }, +); + +test( + "完整会话:/stats + 退出前 flush(200ms 写批不丢,SKILL.md L304 坑)", + { skip: !HAS_KEY }, + async () => { + const r = await runCli([], "/stats\nexit\n", {}); + assert.ok(r.out.length > 0 || r.err.length > 0); + }, +); + +test( + "裸 /provider 被命令处理器拦截,不 fall-through 给模型(SKILL.md L270 坑)", + { skip: !HAS_KEY }, + async () => { + const r = await runCli([], "/provider\nexit\n", {}); + // 无论输出什么,都不应出现"agent 用 bash 探索仓库"的行为痕迹; + // 这里是进程正常退出的弱断言 + 超时即失败(SIGKILL 会返回非 0/137)。 + assert.ok(r.code === 0 || r.code === 1, `exit=${r.code}`); + }, +); diff --git a/tests/env.test.js b/tests/env.test.js new file mode 100644 index 0000000..901ee0c --- /dev/null +++ b/tests/env.test.js @@ -0,0 +1,79 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadEnvFiles, persistCredential } from "../cli/env.js"; + +function tmp() { + return mkdtempSync(join(tmpdir(), "dsh-env-test-")); +} + +test("loadEnvFiles: 解析 KEY=VALUE 并 trim 值", () => { + const dir = tmp(); + const f = join(dir, "env"); + writeFileSync(f, "FOO=bar\nBAZ= qux \n#comment\n"); + const env = {}; + loadEnvFiles([f], env); + assert.equal(env.FOO, "bar"); + assert.equal(env.BAZ, "qux"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("loadEnvFiles: 不覆盖已有环境变量", () => { + const dir = tmp(); + const f = join(dir, "env"); + writeFileSync(f, "FOO=fromfile\n"); + const env = { FOO: "real" }; + loadEnvFiles([f], env); + assert.equal(env.FOO, "real"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("loadEnvFiles: 不存在的文件跳过", () => { + const env = {}; + loadEnvFiles(["/nonexistent/path/env"], env); + assert.deepEqual(env, {}); +}); + +test("persistCredential: 写入并返回目标路径", () => { + const dir = tmp(); + const target = join(dir, "env"); + const r = persistCredential([target], {}, "DEEPSEEK_API_KEY", "sk-test"); + assert.equal(r, target); + assert.equal(readFileSync(target, "utf8"), "DEEPSEEK_API_KEY=sk-test\n"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("persistCredential: 幂等(重复写替换不重复)", () => { + const dir = tmp(); + const target = join(dir, "env"); + persistCredential([target], {}, "KEY", "v1"); + persistCredential([target], {}, "KEY", "v2"); + const content = readFileSync(target, "utf8"); + assert.equal(content.match(/^KEY=/gm).length, 1); + assert.ok(content.includes("KEY=v2")); + rmSync(dir, { recursive: true, force: true }); +}); + +test("persistCredential: 保留文件里其他变量", () => { + const dir = tmp(); + const target = join(dir, "env"); + writeFileSync(target, "OTHER=keep\n"); + persistCredential([target], {}, "KEY", "v"); + const content = readFileSync(target, "utf8"); + assert.ok(content.includes("OTHER=keep")); + assert.ok(content.includes("KEY=v")); + rmSync(dir, { recursive: true, force: true }); +}); + +test("persistCredential: 全部目标失败返回 null", () => { + // 注意:不可用 /proc 路径——Node recursive mkdir 在 procfs 上会挂起(非快速失败)。 + // 用「dirname 是文件」触发 ENOTDIR 快速失败。 + const dir = tmp(); + const notADir = join(dir, "not-a-dir"); + writeFileSync(notADir, ""); + const r = persistCredential([join(notADir, "env")], {}, "KEY", "v"); + assert.equal(r, null); + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/tests/skill-scanner.test.js b/tests/skill-scanner.test.js new file mode 100644 index 0000000..8962b0a --- /dev/null +++ b/tests/skill-scanner.test.js @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseFrontmatter, discover } from "../cli/skill-scanner.js"; + +test("parseFrontmatter: 解析标量字段(name/description/whenToUse)", () => { + const { meta, body } = parseFrontmatter(`--- +name: my-skill +description: Does things +whenToUse: When needed +--- + +# Body +content here +`); + assert.equal(meta.name, "my-skill"); + assert.equal(meta.description, "Does things"); + assert.equal(meta.whenToUse, "When needed"); + assert.ok(body.includes("content here")); +}); + +test("parseFrontmatter: 无 frontmatter 时原样返回", () => { + const { meta, body } = parseFrontmatter("just text"); + assert.deepEqual(meta, {}); + assert.equal(body, "just text"); +}); + +test("parseFrontmatter: 引号去除", () => { + const { meta } = parseFrontmatter("---\nname: \"quoted\"\n---\nbody"); + assert.equal(meta.name, "quoted"); +}); + +test("parseFrontmatter: CRLF 兼容", () => { + const { meta } = parseFrontmatter("---\r\nname: crlf-skill\r\n---\r\nbody"); + assert.equal(meta.name, "crlf-skill"); +}); + +test("discover: 找到 /SKILL.md 和 .md", () => { + const root = mkdtempSync(join(tmpdir(), "dsh-skill-test-")); + mkdirSync(join(root, "dir-skill")); + writeFileSync(join(root, "dir-skill", "SKILL.md"), "---\nname: dir-skill\n---\nbody"); + writeFileSync(join(root, "file-skill.md"), "---\nname: file-skill\n---\nbody"); + const found = discover([root]); + const names = found.map((f) => f.name).sort(); + assert.deepEqual(names, ["dir-skill", "file-skill"]); + rmSync(root, { recursive: true, force: true }); +}); + +test("discover: 不存在的 root 返回空数组", () => { + assert.deepEqual(discover(["/nonexistent"]), []); +}); From 6ca4e3c0a64ec5da3271d79ad9159ca1b43aaf32 Mon Sep 17 00:00:00 2001 From: 4222222 Date: Fri, 14 Aug 2026 18:18:38 +0000 Subject: [PATCH 2/2] test: fake LLM adapter + 3 CLI bug fixes (EOF race, /exit fall-through, flush await) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a scripted FakeAdapter (extends LlmAdapter, DSH_FAKE_LLM=1 + DSH_PROVIDER=fake) so the CLI integration tests exercise real sessions with no network and no API key — the three SKILL.md CLI pitfalls now run in CI instead of being skipped. Bug fixes found by the new tests: 1. EOF race: piped stdin closing before the REPL armed dropped queued lines (close handler exited while lineQueue still held input). Fix: defer exit until the queue drains; ask loop exits when EOF + drained. 2. /exit fall-through: the exit command matched but did not return, so "/exit" was sent to the model as a prompt during the 500ms exit grace (bare-command fall-through, SKILL.md L270 variant). 3. gracefulExit guessed 500ms for the JSONL flush; now awaits ctx.sessionPersistence.flush() before exiting (SKILL.md L304). Tests: 25/25 pass (0 skip) — piped multi-line consumption, flush writes session files, bare /provider never reaches the model. Conformance still IDENTICAL on Node + QuickJS (114 events). --- cli/cli.js | 63 ++++++++++++++++++++++++--- tests/cli.integration.test.js | 82 ++++++++++++++++++++--------------- 2 files changed, 103 insertions(+), 42 deletions(-) diff --git a/cli/cli.js b/cli/cli.js index a1aa5cb..5cd8d70 100644 --- a/cli/cli.js +++ b/cli/cli.js @@ -2,6 +2,7 @@ // pi's shell (the real @earendil-works/pi-tui framework) + DSH's engine AND // state (AgentLoop, ToolRuntime, event-sourced sessions, JSONL persistence). import "../polyfills.js"; +import { FakeLlm } from "../fake-llm.js"; import { parseArgs } from "./args.js"; import { loadEnvFiles, persistCredential } from "./env.js"; import { Context } from "@deepseek-ai/cordis"; @@ -10,7 +11,7 @@ import { SessionStore } from "@deepseek-ai/dsh-session"; import { ToolRuntime } from "@deepseek-ai/dsh-tools"; import { SystemPrompt } from "@deepseek-ai/dsh-system-prompt"; import { AgentLoop } from "@deepseek-ai/dsh-agent-loop"; -import { LlmRuntime, createUserMessage } from "@deepseek-ai/dsh-llm"; +import { LlmRuntime, LlmAdapter, createUserMessage } from "@deepseek-ai/dsh-llm"; import * as fsTools from "@deepseek-ai/dsh-tool-fs"; import * as todoTools from "@deepseek-ai/dsh-tool-todo"; import * as persistenceJsonl from "@deepseek-ai/dsh-session-persistence-jsonl"; @@ -115,9 +116,45 @@ function persistKey(provider, key) { const AGENTS_MD_CAP = 30 * 1024; // keep injected instructions bounded +// Fake scripted adapter for tests/demos (DSH_FAKE_LLM=1 + DSH_PROVIDER=fake): +// same stream contract as the conformance fake, but a full LlmAdapter so it +// can be registered alongside the real LlmRuntime. No network, no key. +class FakeAdapter extends LlmAdapter { + providerInfo(provider) { + return { id: provider, name: "Fake (scripted)" }; + } + // LlmRuntime adapter contract: stream is a method on the adapter that + // returns an async iterable of harness-vocabulary chunks. + stream() { + const chunks = [ + { type: "block-start", index: 0, blockType: "text" }, + { type: "text-delta", index: 0, text: "(default reply)" }, + { type: "block-end", index: 0, block: { type: "text", text: "(default reply)" } }, + { type: "finish", reason: { kind: "stop" } }, + ]; + let i = 0; + // Manual async iterator (no async generators: keeps the CLI bundle + // portable without relying on generator lowering). + return { + [Symbol.asyncIterator]() { + return { + async next() { + if (i < chunks.length) return { value: chunks[i++], done: false }; + return { done: true }; + }, + }; + }, + }; + } + prepareCall(config) { + return { config, stream: (request) => this.stream(request) }; + } +} + const boot = async (ctx) => { if (TTY && !process.env.DSH_NO_BANNER) process.stdout.write(renderBanner()); if (GEMINI_KEY) ctx.llm.registerAdapter(["google"], new GeminiAdapter(GEMINI_KEY)); + if (process.env.DSH_FAKE_LLM) ctx.llm.registerAdapter(["fake"], new FakeAdapter()); // /new: available in every renderer. In the community TUI it restarts the // process with a fresh session id; plain mode handles it in handleLine. ctx.commands.register({ @@ -219,11 +256,13 @@ const boot = async (ctx) => { } }); // Exit on stdin EOF only when idle: a closing pipe must not kill a - // turn that is still streaming. + // turn that is still streaming, nor drop lines still queued from a + // chunk that arrived before the REPL was armed (piped stdin delivers + // whole chunks at once; boot takes hundreds of ms to mount). plainRl.on("close", () => { if (!plainInputActive) return; stdinClosed = true; - if (!busy) gracefulExit(); + if (!busy && lineQueue.length === 0) gracefulExit(); }); } const askUser = (question) => { @@ -236,7 +275,7 @@ const boot = async (ctx) => { let currentProvider = PROVIDER; let currentModel = MODEL; - if (!process.env[PROVIDER_DEFAULTS[currentProvider]?.keyEnv]) { + if (!process.env[PROVIDER_DEFAULTS[currentProvider]?.keyEnv] && !process.env.DSH_FAKE_LLM) { const hasAnyKey = Object.values(PROVIDER_DEFAULTS).some((def) => process.env[def.keyEnv]); if (hasAnyKey) { console.error(`[warn] ${PROVIDER_DEFAULTS[currentProvider].keyEnv} is not set: ${currentProvider} calls will fail with MISSING_CREDENTIAL`); @@ -362,13 +401,17 @@ const boot = async (ctx) => { // Exit with a persistence flush grace: the JSONL backend writes in // 200ms batches; an immediate process.exit() kills the pending write. - const gracefulExit = () => { + // Await the flush itself (async) instead of guessing a fixed delay. + const gracefulExit = async () => { try { - if (agent) ctx.emit("session/flush", agent.session); + if (agent) { + ctx.emit("session/flush", agent.session); + await ctx.sessionPersistence.flush(agent.session); + } } catch { // flush is best-effort on the way out } - setTimeout(() => process.exit(0), 500); + setTimeout(() => process.exit(0), 100); }; async function handleLine(line) { @@ -376,6 +419,7 @@ const boot = async (ctx) => { try { if (/^(\/)?(exit|quit|e|q)(\(\))?$/i.test(trimmed)) { gracefulExit(); + return; } if (trimmed === "") return; if (trimmed === "/provider") { @@ -584,6 +628,11 @@ const boot = async (ctx) => { if (!ui) { const ask = async () => { for (;;) { + // EOF + drained queue: nothing left to process, exit cleanly. + if (stdinClosed && lineQueue.length === 0) { + gracefulExit(); + return; + } const line = await askUser("you> "); await handleLine(line); process.stdout.write("\n"); diff --git a/tests/cli.integration.test.js b/tests/cli.integration.test.js index 256cba8..59fe0fe 100644 --- a/tests/cli.integration.test.js +++ b/tests/cli.integration.test.js @@ -1,16 +1,15 @@ // CLI 集成测试:spawn 构建后的 cli.mjs(DSH_PLAIN=1 管道模式)。 -// 无 key 可跑的路径(--sessions)始终执行;需要真实 API key 的用例 -// (完整会话/管道吞行/close 语义)在 CI 无 key 环境下自动 skip。 +// 用 DSH_FAKE_LLM=1 的脚本化 adapter(无网络、无 key)跑完整会话, +// 覆盖 SKILL.md 记录的三个 CLI 层坑:管道吞行、退出 flush、裸命令 fall-through。 import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "cli.mjs"); -const HAS_KEY = Boolean(process.env.DEEPSEEK_API_KEY || process.env.GEMINI_API_KEY); function runCli(args, input, env = {}) { return new Promise((resolve) => { @@ -32,6 +31,14 @@ function runCli(args, input, env = {}) { }); } +const FAKE = (sessionsDir) => ({ + DSH_FAKE_LLM: "1", + DSH_PROVIDER: "fake", + DSH_SESSIONS: sessionsDir, +}); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + test("--sessions 无会话时正常退出 0(CLI boot 冒烟)", async () => { const dir = mkdtempSync(join(tmpdir(), "dsh-sess-test-")); const r = await runCli(["--sessions"], "", { DSH_SESSIONS: dir }); @@ -39,37 +46,42 @@ test("--sessions 无会话时正常退出 0(CLI boot 冒烟)", async () => { assert.equal(r.code, 0, `stderr: ${r.err.slice(0, 500)}`); }); -test("管道 EOF 后进程优雅退出(不僵死)", async () => { - const r = await runCli(["--sessions"], "", {}); - assert.ok(r.code === 0 || r.code === 1, `exit=${r.code}`); +test("管道连续输入全部被消费,不吞行(SKILL.md 管道吞行坑)", async () => { + const dir = mkdtempSync(join(tmpdir(), "dsh-pipe-test-")); + // 两行输入各触发一次 turn;fake LLM 每次回 "(default reply)"。 + // 吞行 bug 会导致回复次数 < 2。 + const r = await runCli([], "hello\nworld\n/exit\n", FAKE(dir)); + const replies = (r.out.match(/\(default reply\)/g) || []).length; + rmSync(dir, { recursive: true, force: true }); + assert.ok( + replies >= 2, + `期望至少 2 次回复(两行都被消费),实际 ${replies}。输出: ${r.out.slice(0, 400)}`, + ); }); -test( - "完整会话:管道连续输入被消费(SKILL.md 管道吞行坑)", - { skip: !HAS_KEY }, - async () => { - const r = await runCli([], "hello\n/stats\nexit\n", {}); - // 弱断言:进程退出且输出里有会话痕迹;不依赖具体文案。 - assert.ok(r.out.length > 0 || r.err.length > 0); - }, -); - -test( - "完整会话:/stats + 退出前 flush(200ms 写批不丢,SKILL.md L304 坑)", - { skip: !HAS_KEY }, - async () => { - const r = await runCli([], "/stats\nexit\n", {}); - assert.ok(r.out.length > 0 || r.err.length > 0); - }, -); +test("/stats 后退出前 flush 写盘,200ms 批窗不丢(SKILL.md L304 坑)", async () => { + const dir = mkdtempSync(join(tmpdir(), "dsh-flush-test-")); + // 先有一次对话(产生会话事件),再 /stats + /exit——纯命令会话无事件可写, + // 无法验证 flush。退出时 gracefulExit 必须 await flush 完成再 exit。 + const r = await runCli([], "hello\n/stats\n/exit\n", FAKE(dir)); + // 等待写批窗口(200ms)落盘 + await sleep(1000); + const files = readdirSync(dir, { recursive: true }).map(String); + rmSync(dir, { recursive: true, force: true }); + assert.ok( + files.some((f) => f.includes("session")), + `会话文件应已写入。实际文件: ${files.slice(0, 6).join(", ") || "(空)"}`, + ); +}); -test( - "裸 /provider 被命令处理器拦截,不 fall-through 给模型(SKILL.md L270 坑)", - { skip: !HAS_KEY }, - async () => { - const r = await runCli([], "/provider\nexit\n", {}); - // 无论输出什么,都不应出现"agent 用 bash 探索仓库"的行为痕迹; - // 这里是进程正常退出的弱断言 + 超时即失败(SIGKILL 会返回非 0/137)。 - assert.ok(r.code === 0 || r.code === 1, `exit=${r.code}`); - }, -); +test("裸 /provider 被命令处理器拦截,不 fall-through 给模型(SKILL.md L270 坑)", async () => { + const dir = mkdtempSync(join(tmpdir(), "dsh-cmd-test-")); + const r = await runCli([], "/provider\n/exit\n", FAKE(dir)); + rmSync(dir, { recursive: true, force: true }); + // 若 fall-through,/provider 会作为 prompt 发给模型 → 输出含 fake 回复。 + // 断言模型未被调用 = 命令被拦截。 + assert.ok( + !r.out.includes("(default reply)"), + `裸 /provider 不应触发模型调用。输出: ${r.out.slice(0, 400)}`, + ); +});