Development Guide

August 29, 2026 · View on GitHub

English | 中文

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

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:

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/:

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

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:

  • FeishuFEISHU_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 APIDEEPSEEK_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-<suite>/profiles/feishu-dev — create it with dsh plugin --profile feishu-dev add link:<checkout>, 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 NEWEST @deepseek-ai/* release via the @next dist-tag (not the lockfile-pinned version, and not @latest — for most harness packages npm latest still points at the old 0.0.1-rc.x line). A red canary means an upstream breaking change reached our code; see AGENTS.md → "Adapting to a new dsh release".

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:<checkout> recipe. CI prepares both profiles.

Scenario coverage matrix
ScenarioTest
Daemon restart resumes the same session; /export after restart ships a transcript spanning both sidesrestart resumes the same session
/status read-only while a turn runs/status is read-only
Bare /repo posts the picker cardbare /repo posts the project picker card
/group creates a group; @-turn works there/group creates a group chat
Mention modes never / ambient / topicthree groupMentionMode= tests
allowedChats (env) gates whole chatsallowedChats 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 messagefree-text question
Question Cancel settles empty answersquestion Cancel
Group approval/question cards @ the requester; p2p cards don'ttwo mention tests
Redelivered message id is dedupedmessage dedup
unknownCommand=passthrough routes unknown slashes to the modelunknownCommand=passthrough
Stop mid-turn swaps the reaction to the stopped emojistop 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 (<caseId>-<runId>) 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).

# 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:<checkout> 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:

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:

    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 <module> <action> <entities> with the real ids, so you can trace one message, card, session, or chat through the pipeline:

ModuleLines you will seeAnswers
index[feishu] starting surface, routing: …, host services: …What config won; which host services mounted (rename/archive/permission/plan/llm)
session-mapsession map: minted/remint/chat X now Y/cwd …Why a chat got a new session; cwd pin changes
bridgemessage <id> -> slash/turn, command /x -> <kind>, agent resolve … live/resume/create/rebind, session event <type>, card action <kind> on card <id>Message routing; command outcomes; the agent ladder; every inbound event
transporttransport ws state -> …, sendText/sendCard/updateCard/…Long-connection health; every outbound message/card with ids
streamingstreaming open/patch/finalize, streaming event <type> -> chat, tool/call/tool/resultTurn lifecycle; which card was patched and when; tool activity
panelpanel action <kind> on card <id>, panel OPEN/PUSH/POP/…, panel update card <id>Which card each tap updates (per-card state machine)
interactionsapproval request <id>, approval <id> settled, question <id> settledApproval/question lifecycle
actionspanel action <kind>: transition/refused/operationPanel gate and lifecycle decisions

A full turn reads top to bottom like: inbound message m1 -> turnstreaming beginTurnstreaming event assistant/chunkstreaming tool/calltransport updateCard <card-id>streaming event turn/endstreaming finalize <status>. 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<MessageKey, string>; 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/<module>.ts with JSDoc on the module and its exported functions.
  2. Create tests/<module>.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.

DocUpdated 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.tsany 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.mdagent guidance, conventions, workflow (this file)
CONTRIBUTING.md / SECURITY.mdcontribution 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:

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:

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":"<branch>","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):

## What

<one line per change, concrete>

## Why

<context: the problem this solves, references to issues/PRs if any>

## Docs

<docs updated by this PR per the Documentation map, or "none no doc
surface affected". README changes: held for maintainer review.>

## Verification

<gates run + how the behavior was confirmed (manual steps, test names)>

Watch CI until it concludes — the workflow runs the full gate matrix, including the real-composition integration suite:

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: "<PR title> (#<number>)" — one Conventional Commit per PR on main, each traceable to its PR:

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/<number>/merge \
  --data '{"commit_title":"<PR title> (#<number>)","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

Releases are tag-driven from a frozen release/* branch, never from main — main is a development branch and may carry unreleased work (e.g. the next dsh compat pass), so a release must be cut from the exact commit that should ship.

Version tracks

dsh-feishu tracks two DSH versions, one per consumer track (they differ — never assume they match):

dsh-feishu trackShipsAdapted for DSHHow users install (README sections)
main branchnext release's workdsh @next (latest pre-release)"Install from source"
npm @latest (GitHub latest release)the current stable releasedsh @latest"Install from npm"

We publish only the @latest npm tag — there is no npm @next for dsh-feishu; users who want the newest code install from main. README carries both compatibility badges (main → dsh @next, latest release → dsh @latest), and the two tracks are verified separately:

  • ci.yml and the Canary (main vs dsh@next) workflow exercise main against dsh @next (main's lockfile pins it; canary lifts to the newest @next when upstream publishes faster);
  • the Release compat (npm latest vs dsh@latest) workflow lifts the repo to dsh @latest — the combination the next release must ship against.

The two adapted-for-DSH versions (the @next for main, the @latest for the npm release) are recorded declaratively in dsh-version.json at the repo root (dsh.stable = A, dsh.next = B). It is 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 README Notes drift 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 a label on a green one, landing as a worktree PR (merge and npm publish stay human-gated).

Releasing

git checkout -b release/vX.Y.Z <commit>   # cut from the exact commit to ship
node scripts/release.mjs <major|minor|patch>

scripts/release.mjs refuses to run outside a release/* branch, bumps package.json, runs the CI gates (through scripts/run-gates.mjs — direct binaries, no pnpm store dependency), commits chore: release vX.Y.Z, then pushes the release branch and the v* tag to origin. The Release workflow then publishes to npm (NODE_AUTH_TOKEN — the same registry-token pattern the DeepSeek Harness release workflow uses) and creates a GitHub Release. Before the first public release, rotate the Feishu app secret (see SECURITY.md).