# Development Guide English | [中文](development.zh.md) Setup, build, test, and local-verification workflow for dsh-feishu. ## Prerequisites - Node.js >= 22.13 (ESM, NodeNext) - pnpm (the dsh profile tooling forwards to pnpm) - A dsh installation: `npm install -g @deepseek-ai/dsh` (the npx cache of `@deepseek-ai/dsh` also works for local verification) ## Install dependencies ```sh pnpm install ``` > Local-environment note: pnpm 10+ reads settings from `pnpm-workspace.yaml` > and the user config directory; the default store location may be unwritable > on some hosts (e.g. a read-only home mount). In that case point the store at > a writable path once, using a redirected HOME: > > ```sh > mkdir -p _dev/home && export HOME="$(pwd)/_dev/home" > pnpm config set store-dir "$(pwd)/_dev/pnpm-store" > pnpm config set cache-dir "$(pwd)/_dev/pnpm-cache" > # keep HOME exported for every pnpm invocation in this shell > ``` ### Local toolchain If `pnpm` is not on your `PATH`, use a local install and point every pnpm invocation — including the profile's spawned `pnpm`, since `dsh plugin` forwards to it — at writable store/cache paths under the repo's `_dev/`: ```sh export PATH="$(pwd)/_dev/pnpm/node_modules/.bin:$PATH" export npm_config_store_dir="$(pwd)/_dev/pnpm-store" export npm_config_cache_dir="$(pwd)/_dev/pnpm-cache" export XDG_CACHE_HOME="$(pwd)/_dev/xdg-cache" # node-gyp builds ``` `npm_config_*` env vars override project config, so `dsh plugin`'s inner `pnpm add` uses the same store without editing the profile. `XDG_CACHE_HOME` redirects node-gyp's header cache (the default `~/.cache/node-gyp` may sit on a read-only mount) — without it native modules such as node-pty fail to build. ## Gates ```sh pnpm run lint # Biome: lint + format check on src/ and tests/ pnpm run typecheck # tsc --noEmit over src/ and tests/ pnpm run test # Vitest unit + integration tests pnpm run build # tsc emit to lib/ (declaration + source maps) ``` All four must pass before committing; CI runs the same four. ## Layout ``` src/ # plugin source; one module per concern, each with tests tests/ # vitest suites (never under src/) docs/ # documentation (EN + .zh.md) examples/ # runnable examples (profiles, configs) scripts/ # repo tooling ``` Tests use the "fake context" pattern for plugin-level coverage: hand-built stubs of the cordis services the module touches (see `tests/index.spec.ts`), plus pure-function tests for the module's logic. ### Integration test `tests/integration/real-composition.spec.ts` boots a **real dsh process** from a real profile and runs a real agent turn, mocking only the two external services: - **Feishu** — `FEISHU_TRANSPORT=memory` swaps the wire for the file-channel memory transport (`src/memory-transport.ts`): the test drops a message into `inbox/`, the surface processes it, and every send/update lands in `outbox/`. - **LLM API** — `DEEPSEEK_BASE_URL` points the real DeepSeek adapter at a local mock server (`tests/integration/mock-llm-server.ts`). The suite asserts the full surface: message → session → agent turn → card posted and patched → **final answer carried by the card** (it finalizes green in place), plus the card actions, the session lifecycle, the web command wrappers, and the working-directory gate. A scripted mock LLM (`setScripts`, `holdNextResponse`, and an `error` chunk that answers HTTP 500) drives tool calls, reasoning, error turns, and retries. It self-skips unless the prerequisites are met: - The dsh CLI is resolvable (`$DSH_BIN`, or `dsh` on `PATH`). - A prepared profile exists at `$FEISHU_INT_*_DSH_HOME/profiles/feishu-dev` (default `_dev/dsh-home-/profiles/feishu-dev` — create it with `dsh plugin --profile feishu-dev add link:`, see the "Verifying the bundle" section below). Deliberately independent of the ambient `DSH_HOME` so the test never touches another dsh home. **Each integration suite uses its OWN home** (`dsh-home-attachments`, `dsh-home-rich-text`, `dsh-home-wait-instruction`, `dsh-home-real`, `dsh-home-scenarios`, `dsh-home-outbound`, `dsh-home-turn-produced-files`): vitest runs the suites in parallel, and sharing one `_dev/dsh-home/feishu/session-map.json` made concurrent dsh processes race their writes and silently drop one suite's chat→session binding (CI-only flakes, e.g. the session-lifecycle test reading the file and finding its chat missing). - The checkout is built (`pnpm run build`). CI runs the suite on every push (both node-version legs): the workflow builds the checkout, prepares the profile, and runs the tests with `FEISHU_INT_REQUIRED=1` so a missing prerequisite fails the job loudly instead of silently skipping. The dsh CLI is a devDependency (`@deepseek-ai/dsh`) and native build scripts (node-pty and friends) are allowed in `pnpm-workspace.yaml` — no credentials are involved; the Feishu and LLM mocks above are what make the suite runnable without secrets. A separate **canary workflow** (`.github/workflows/canary.yml`) runs the same suite daily (UTC 02:00) and on demand against the **npm `@latest` CLI** — the version users actually install, not the lockfile-pinned one. It pins `@deepseek-ai/dsh` to `@latest` and sets the rest of the family to the caret of that version (pointing the sub-packages at a dist-tag would install ancient `0.0.1-rc.x` lines — only the CLI's `latest` tag is current). A red canary means an upstream breaking change reached our code; see AGENTS.md → "Adapting to a new dsh release". ```sh pnpm run build # ensure lib/ is current (the profile links the checkout) pnpm run test # unit + integration (integration self-skips as needed) DSH_BIN=/path/to/dsh pnpm run test -- tests/integration/real-composition.spec.ts FEISHU_INT_REQUIRED=1 pnpm run test # fail (not skip) when a prerequisite is missing ``` Turn-running tests pin a working directory first (`/cd`, required by the working-directory gate) and the group tests inject the bot's open id via `FEISHU_MOCK_BOT_OPEN_ID`. New sessions fire an extra title-generation LLM completion — assert card contents, never exact completion counts. The `FEISHU_TRANSPORT=memory` seam is also handy for manual debugging: inject a fake message by writing a JSON file into `$FEISHU_MEMORY_DIR/inbox/` while the surface runs. #### Scenario suite (two real-process suites, two dsh homes) `tests/integration/scenarios.spec.ts` is a second real-process suite for edge scenarios: daemon-restart durability, group mention modes and allowlists (via the `FEISHU_*` env seams), `/group` + `/repo`, every question-card variant, proactive mentions, dedup, passthrough, and the stopped-turn reaction swap. (Session replay has one surface — `/export`; `/history` was removed by decision as redundant and ugly.) Because vitest runs test **files** in in parallel, the two suites must not share a dsh home (both persist the session map + logs): the scenario suite defaults to `_dev/dsh-home-scenarios` (`FEISHU_INT_SCENARIOS_DSH_HOME` overrides), prepared with the same `dsh plugin --profile feishu-dev add link:` recipe. CI prepares both profiles. ##### Scenario coverage matrix | Scenario | Test | |---|---| | Daemon restart resumes the same session; `/export` after restart ships a transcript spanning both sides | `restart resumes the same session` | | `/status` read-only while a turn runs | `/status is read-only` | | Bare `/repo` posts the picker card | `bare /repo posts the project picker card` | | `/group` creates a group; @-turn works there | `/group creates a group chat` | | Mention modes `never` / `ambient` / `topic` | three `groupMentionMode=` tests | | `allowedChats` (env) gates whole chats | `allowedChats env` | | Solo-group relaxation (`1u,1b`) accepts un-@ | `solo-group relaxation` | | Multi-select toggles + Submit (retargeted cards) | `multi-select question` | | Free-text question answered by a chat message | `free-text question` | | Question Cancel settles empty answers | `question Cancel` | | Group approval/question cards @ the requester; p2p cards don't | two mention tests | | Redelivered message id is deduped | `message dedup` | | `unknownCommand=passthrough` routes unknown slashes to the model | `unknownCommand=passthrough` | | Stop mid-turn swaps the reaction to the stopped emoji | `stop mid-turn swaps the received reaction` | | `/export` transcript includes tool rows | `/export after a tool-calling turn` | ### Real-client E2E (feishu.cn web) The E2E suite drives the **actual feishu.cn web client** in a headless browser and exercises the bot like a user: open a group chat, send a slash command, click card buttons, assert what renders. It runs the real dsh process (dedicated bot app, mock LLM) plus a real browser — the only layer where the Feishu wire and the client are both real. Run `pnpm run e2e:setup` once (dedicated test account; the QR scans are the only human steps, and the setup is idempotent — re-running never re-scans an already-exported login) — afterwards `pnpm run e2e:ui` is hands-free. Each test case creates its own group chat (`-`) through the backend (the same `im.v1.chat.create` call `/group` wraps), so cases never share a chat page. It is **not** part of CI: it needs a real bot app and a browser session. Design, constraints, runbook, and the captured web selectors live in `docs/e2e-testing.md`. ## Verifying the bundle in a real dsh profile The bundle must mount into a real dsh profile. Use an isolated `DSH_HOME` so the verification never touches a production profile: > For creating/configuring the Feishu app itself — one QR scan, no > web-console work — see `docs/feishu-setup.md` → "Quick setup" > (`pnpm run setup:feishu`). ```sh # From the checkout root: export DSH_HOME="$(pwd)/_dev/dsh-home" # git-ignored dsh plugin --profile feishu-dev add "link:$(pwd)" dsh --profile feishu-dev --dump-config # confirm the feishu row is composed timeout 30 dsh --profile feishu-dev # boot; expect the "[feishu]" log lines ``` - The first `dsh plugin` call initializes the profile (bundles = `['@deepseek-ai/dsh-base']`), runs `pnpm add link:` inside it, and appends `@dsh-feishu/dsh-feishu` to `dsh.profile.bundles` because the manifest declares `dsh.bundle.patch`. If the profile's pnpm store is unwritable, add `storeDir` / `cacheDir` to the profile's `pnpm-workspace.yaml` (that file is the pnpm 10+ settings home). - Booting without credentials logs the not-configured notice and registers `feishu-status`; credentials come from the `appId`/`appSecret` config keys or the `FEISHU_APP_ID` / `FEISHU_APP_SECRET` environment variables. - Teardown: `rm -rf _dev/dsh-home` (or drop the `feishu-dev` profile). ## Running the live test bot For end-to-end verification against the REAL Feishu platform (a card that only misbehaves on device, a timing issue, a panel interaction), boot the bot with the repository's test-app credentials instead of the integration mocks. The checkout keeps a git-ignored test-bot environment: ```sh source _dev/bot-env.sh # sets DSH_HOME=_dev/dsh-home + sources _dev/secrets.env ``` `_dev/secrets.env` holds `FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `DEEPSEEK_API_KEY` for the test app; `bot-env.sh` also unsets the proxy vars (the Feishu long connection must NOT go through the sandbox proxy). - **Debug tracing**: set `FEISHU_DEBUG=1` to print debug lines across the whole surface (see "Debug logging" below for the full line map). The panel logs show exactly which card each action updates — the tool for "which card reacted" questions. - **Exactly ONE bot process**: before/after every start or restart, verify only one process is attached to the test app (see `docs/pitfalls.md` → "Environment and proxy quirks" — a stray second bot makes cards update chaotically because each process owns its own panel state). - **Start detached** so the bash job does not reap it: ```sh source _dev/bot-env.sh && export FEISHU_DEBUG=1 nohup ./node_modules/.bin/dsh --profile feishu-dev > _dev/bot.log 2>&1 < /dev/null & disown sleep 15 && grep "bridge ready" _dev/bot.log ``` Stop with `pkill -f "bin.js --profile feishu-d[e]v"` and verify zero processes remain. ## Debug logging `FEISHU_DEBUG=1` enables `logger.debug(...)` tracing across the whole surface (the console exporter gates it; without the env var production stays quiet — see `docs/pitfalls.md` → "Debug tracing needs `FEISHU_DEBUG=1` AND the exporter's `levels`"). Every log line follows ` ` with the real ids, so you can trace one message, card, session, or chat through the pipeline: | Module | Lines you will see | Answers | |---|---|---| | `index` | `[feishu] starting surface`, `routing: …`, `host services: …` | What config won; which host services mounted (rename/archive/permission/plan/llm) | | `session-map` | `session map: minted/remint/chat X now Y/cwd …` | Why a chat got a new session; cwd pin changes | | `bridge` | `message -> slash/turn`, `command /x -> `, `agent resolve … live/resume/create/rebind`, `session event `, `card action on card ` | Message routing; command outcomes; the agent ladder; every inbound event | | `transport` | `transport ws state -> …`, `sendText/sendCard/updateCard/…` | Long-connection health; every outbound message/card with ids | | `streaming` | `streaming open/patch/finalize`, `streaming event -> chat`, `tool/call`/`tool/result` | Turn lifecycle; which card was patched and when; tool activity | | `panel` | `panel action on card `, `panel OPEN/PUSH/POP/…`, `panel update card ` | Which card each tap updates (per-card state machine) | | `interactions` | `approval request `, `approval settled`, `question settled` | Approval/question lifecycle | | `actions` | `panel action : transition/refused/operation` | Panel gate and lifecycle decisions | A full turn reads top to bottom like: `inbound message m1 -> turn` → `streaming beginTurn` → `streaming assistant-stream chunk` → `streaming tool/call` → `transport updateCard ` → `streaming event turn/end` → `streaming finalize `. When something misbehaves, grep for the message/card/session id and the discontinuity in the chain marks where the surface lost it. ## Internationalization (i18n) User-visible strings live in locale catalogs under `src/i18n/`, not inline in card/button code: - `src/i18n/en-US.ts` — the BASE catalog and the type source of `MessageKey` (`as const`). Add new user-facing copy HERE first. - `src/i18n/zh-CN.ts` — typed as `Record`; key parity is enforced at compile time (a missing or extra translation fails `pnpm run typecheck`) plus by `tests/i18n.spec.ts`. - `src/i18n/index.ts` — the engine: flat dot-namespaced keys, `{name}` interpolation tokens, loud failure on an unknown key, and the active-locale singleton (`setActiveLocale` at `apply()` startup; modules import the pure `t(key, params?)`). Conventions: 1. Emojis live INSIDE catalog values so each locale owns its whole label. 2. Every `{token}` in a value must be passed by the caller; placeholder NAMES must match across locales (`tests/i18n.spec.ts` compares them per key). 3. Always translate at CALL time. A module-level `t(...)` initializer freezes the default locale before `apply()` configures it (caught by regression: the status-card connection labels). 4. Scope: cards, buttons, panel views/actions, command feedback, gates, and notices are translated. Registry-facing command `description`/`usage` (contract text surfaced to harness tooling), agent-prompt content, and debug logs stay English by design. The active locale resolves from the profile's `locale` option → the `FEISHU_LOCALE` environment variable → `en-US` (see `docs/feishu-setup.md` → "Surface language"). `tests/i18n-rendering.spec.ts` flips the locale to zh-CN and asserts real rendered output for the main surfaces — extend it when a new user-facing view lands. ## Adding a feature module 1. Create `src/.ts` with JSDoc on the module and its exported functions. 2. Create `tests/.spec.ts` covering its behavior (and disposal where it registers into a registry). 3. Wire it through `src/index.ts` (feature-detect optional services with `ctx.get`). 4. Update the relevant `docs/` page. 5. Run all gates; commit with a Conventional Commit message. ## Documentation map Every doc below has an owner change type; a PR that touches that surface updates the doc IN THE SAME PR (see AGENTS.md → "Docs move with their feature"). `*` marks docs whose changes require maintainer review before they land. | Doc | Updated when | | --- | --- | | `README.md` / `README.zh.md` `*` | user-facing surface: install, quickstart, features, compatibility, badges — ANY edit requires maintainer review before it lands | | `docs/architecture.md` (+ `.zh.md`) | structure, state machines, surfaces, data flow | | `docs/ux-specification.md` (+ `.zh.md`) | interactive behavior: cards, panels, actions, approvals, questions | | `docs/feishu-setup.md` (+ `.zh.md`) | Feishu setup, permissions, events, callbacks (kept in sync with `src/setup/feishu-manifest.json`) | | `docs/development.md` (+ `.zh.md`) | dev workflow, commands, gates, toolchain, PR/CI process | | `docs/e2e-testing.md` (+ `.zh.md`) | E2E UI suite: scenarios, runbook, constraints, captured web selectors | | `docs/features.md` (+ `.zh.md`) | feature list / TODO tracker — every shipped or planned feature updates its row | | `src/i18n/en-US.ts` + `src/i18n/zh-CN.ts` | any user-facing string change: add the key(s) to BOTH catalogs in the same PR | | `docs/pitfalls.md` (+ `.zh.md`) | field-proven failure modes; every entry ships with its regression test | | `docs/portable-deployment.md` (+ `.zh.md`) | the self-contained/green deployment package: how to build it, its multi-instance model, and per-instance deployment | | `AGENTS.md` | agent guidance, conventions, workflow (this file) | | `CONTRIBUTING.md` / `SECURITY.md` | contribution guidance / security posture (rare, deliberate) | A behavior change with no doc impact is possible but must be stated: the PR body's `## Docs` line says which docs changed, or "none — no doc surface affected". README edits always sit in their own commit so the maintainer can review or drop them independently. ## Pull requests and CI _Maintainer-only automation — contributors open PRs through the GitHub UI._ Merge only through a PR with green CI — never push to main. GitHub API access uses the repo-scoped fine-grained PAT at `_dev/gh-token` (chmod 600, owned by the developer, never committed). Read it into a variable per call and never echo it: ```sh TOKEN=$(cat _dev/gh-token) ``` Open a PR (head = your pushed branch, base = `main`). The PR title must be a Conventional Commit (`feat: …`, `fix: …`, `docs: …`, `chore: …`, optionally scoped like `chore(ci): …`) — it is what lands on `main` as the merge title, and history stays uniform when every PR reads as one commit: ```sh curl -s -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Accept: application/vnd.github+json' -H 'Content-Type: application/json' \ https://api.github.com/repos/PGZXB/dsh-feishu/pulls \ --data '{"title":"...","head":"","base":"main","body":"..."}' ``` The PR body follows a fixed template — what changed, why, which docs moved with it, and how it was verified (a reviewer reads the body, not the commits; the merge only keeps the title): ```md ## What ## Why ## Docs ## Verification ``` Watch CI until it concludes — the workflow runs the full gate matrix, including the real-composition integration suite: ```sh SHA=$(git rev-parse HEAD) curl -s -H "Authorization: Bearer $TOKEN" \ -H 'Accept: application/vnd.github+json' \ "https://api.github.com/repos/PGZXB/dsh-feishu/actions/runs?head_sha=$SHA" ``` Merge once the PR's `mergeable_state` is `clean` (checks green). Always **squash-merge** (`merge_method: "squash"`) with `commit_title: " (#)"` — one Conventional Commit per PR on `main`, each traceable to its PR: ```sh curl -s -X PUT -H "Authorization: Bearer $TOKEN" \ -H 'Accept: application/vnd.github+json' -H 'Content-Type: application/json' \ https://api.github.com/repos/PGZXB/dsh-feishu/pulls//merge \ --data '{"commit_title":" (#)","merge_method":"squash"}' ``` Do NOT use "merge" (adds a merge commit) or "rebase" (replays a multi-commit PR as several `main` commits with no PR trace). Before opening the PR, rebase onto the latest `origin/main` and re-run the gates: the main tree moves under concurrent work, and conflicts are cheapest to fix before the PR exists. If CI is red, fix in the worktree and re-push — GitHub re-runs checks on the new head. See AGENTS.md → "Worktree + PR workflow" for the end-to-end practice. ## Publishing A release is a **reviewed release PR that merges first, then a tag cut on merged `main`** — never a direct tag push from a side branch. The version bump itself is always prepared on a frozen `release/vX.Y.Z` branch cut from the exact commit that should ship (main is a development branch and may carry unreleased work, e.g. the next dsh compat pass), but it lands through the same PR review as everything else, and the publish is triggered only after that PR has merged. The ordering is not cosmetic. GitHub's generated release notes (`--generate-notes`) resolve their "What's Changed" baseline from the PREVIOUS tag reachable in the new tag's history. A tag cut on a release branch that is later squash-merged never becomes an ancestor of later commits, so the next release silently falls back to an ancient tag and lists months of unrelated PRs. Tags cut on merged `main` keep every tag an ancestor of the next, and the Release workflow refuses tags that are not on main (`release.yml` → "Guard"). ### Version tracks dsh-feishu tracks **one** dsh version: **dsh `@latest`**. Both `main` (installed from git) and the npm `@latest` release are adapted to it, so the repo makes a single compatibility promise: | dsh-feishu track | Ships | Adapted for DSH | How users install (README sections) | |---|---|---|---| | `main` branch | next release's work | **dsh `@latest`** | "Install from source" | | npm `@latest` (GitHub latest release) | the current stable release | **dsh `@latest`** | "Install from npm" | Other dsh dist-tags — the pre-release and alpha lines — are ignored on purpose. Only the version a user gets from the npm `@latest` tag is a promise; maintaining a second promise for a pre-release line meant adapting the same code twice whenever that line ran ahead of `@latest` (adapt for `@latest`, release it, then adapt again for the pre-release) while telling users two different stories. A newer pre-release is not by itself a reason to change anything. Only the dsh **CLI** carries a meaningful npm `latest` tag: the family's sub-packages publish only pre-release tags, and their own `latest` still points at ancient `0.0.1-rc.x` lines. "dsh `@latest`" therefore means `npm view @deepseek-ai/dsh@latest version`, with the sub-packages resolved by the carets that CLI declares — exactly what a user's `npm i @deepseek-ai/dsh@latest` resolves to. Verification: - `ci.yml` runs the gates on every push against the lockfile-pinned set; - the `Canary (main vs dsh@latest)` workflow pins the CLI to the npm `@latest` version daily and runs the suite against that fresh combination — the oracle for this track (a red run means `main` needs a compatibility fix); - the `Release compat (npm latest vs dsh@latest)` workflow installs the PUBLISHED npm package beside `@latest` and boots it, proving the shipped artifact still installs (a red run with a green Canary just means the release is older than `main`). The tracked version is recorded declaratively in `dsh-version.json` at the repo root (`dsh.latest`), the single source of truth: the README Note is regenerated from it (`node scripts/render-version-note.mjs`) and `pnpm run check` (`checkVersionTrack()`) fails if the Note, the CLI pin in `package.json`, or a harness peer range drifts from it. The `dsh-version-track` skill (`.dsh/skills/dsh-version-track/`) diagnoses the canary / release-compat runs and adapts the code on a red run or refreshes the label on a green one, landing as a worktree PR (merge and npm publish stay human-gated). ### Releasing The release driver (`scripts/release.mjs`) has two phases — run them in order: **Phase 1 — prepare the release PR** (on a fresh `release/vX.Y.Z` branch): ```sh git checkout -b release/vX.Y.Z # cut from the exact commit to ship node scripts/release.mjs prepare ``` `prepare` bumps `package.json`, runs the CI gates (through `scripts/run-gates.mjs` — direct binaries, no pnpm store dependency) and the real-client E2E acceptance (`--skip-e2e` exists as an explicit escape hatch for when the E2E environment cannot be provisioned — never the default), commits `chore: release vX.Y.Z`, pushes the branch, and opens the release PR against `main` (`gh pr create` when `gh` is installed, else it prints the compare URL). **No tag, no publish happens in this phase.** **Phase 2 — review, merge, tag** (after the release PR merges): ```sh git checkout main && git pull node scripts/release.mjs tag ``` `tag` verifies main is clean and up to date with `origin/main`, refuses a tag that already exists, cuts `vX.Y.Z` at merged main's HEAD (the version comes from `package.json`), and pushes it. The [Release workflow](../.github/workflows/release.yml) then re-runs the gates, publishes to npm (`NODE_AUTH_TOKEN` — the same registry-token pattern the DeepSeek Harness release workflow uses) and creates the GitHub Release with generated notes. The workflow's "Guard" step fails any tag that does not point at merged main, so an accidental side-branch tag cannot ship. After the workflow goes green: 1. verify the Actions run and the npm dist-tag; 2. bump `dshFeishu.npmLatest` in `dsh-version.json` on main (a one-line `chore:` commit) — it records a published fact, so it follows the publish; 3. skim the release's What's Changed — with the ancestry intact it lists exactly the PRs since the previous tag; if you ever must tag off main for a hotfix from a release branch instead, regenerate the notes with an explicit baseline (`POST /repos/…/releases/generate-notes` with `previous_tag_name`) and patch the release body. Before the first public release, rotate the Feishu app secret (see `SECURITY.md`).