Skip to content

feat(dsh): add DeepSeek Harness (dsh) agent support - #1682

Open
baizhu945 wants to merge 5 commits into
chenhg5:mainfrom
baizhu945:pr/dsh-agent
Open

feat(dsh): add DeepSeek Harness (dsh) agent support#1682
baizhu945 wants to merge 5 commits into
chenhg5:mainfrom
baizhu945:pr/dsh-agent

Conversation

@baizhu945

Copy link
Copy Markdown
Contributor

Summary

Adds a first-class DeepSeek Harness (dsh) agent to cc-connect. Users can chat with dsh through Feishu/Telegram/... with full multi-turn context, live tool-call visibility, and interactive permission approval cards.

How it works

  • Each user message spawns dsh --profile headless as a one-shot run that resumes the same persisted dsh session (--session-id, create-or-resume), so conversation context carries across turns.
  • --model / --mode are passed on every run, making /model and /mode work like other agents:
    • /model reads dsh's settings.yaml model catalog (fallback: deepseek-v4-flash / deepseek-v4-pro)
    • /mode exposes read-only / workspace-write / danger-full-access / confirm
  • In --jsonl mode (the dsh headless driver protocol this feature builds on):
    • text/thinking deltas and tool calls stream into the chat in real time
    • approval requests (confirm-mode write/execute interception + workspace-write sandbox escalations) render as permission cards; the human's Allow/Deny is written back to the runner's stdin via RespondPermission
  • /session shows dsh's real session titles (last session/title event, fallback first user message), parsed from the zstd-compressed session logs (new dep: klauspost/compress).

Companion change (not in this PR)

The dsh side needs --session-id / --model / --mode / --jsonl headless-runner flags. Those live in deepseek-ai/deepseek-harness, which currently does not accept external pull requests (per its CONTRIBUTING.md). The headless runner changes are small and self-contained (startup flag parsing + runner streaming/approval wiring + confirm-mode ask interception + regression tests) — the patch is documented in the config example.

Testing

  • go test -tags no_web ./... — all packages pass (LANG=C; one pre-existing locale-dependent core test fails under zh_CN only, unrelated to this change)
  • go test ./core/ -run TestCUJ — passes
  • New unit tests cover: session create/resume id flow, --jsonl event mapping (text/thinking/tool call/result), approval request relay + stdin response (allow & reject), session-title parsing from zstd logs, mode/model switching
  • End-to-end verified against a real dsh headless build: approval → tool executes; rejection → Error: the user rejected tool "write" and the file is not created

Notes

  • No breaking changes; new agent type dsh is additive.
  • i18n: no new user-facing strings (reuses existing Msg* keys).

Bridges cc-connect to the DeepSeek Harness CLI (dsh) in headless profile:
every user message spawns `dsh --profile headless` as a one-shot run that
resumes the same persisted dsh session (context carries across turns),
then prints the final assistant text back to the chat.

The installed dsh must provide the --session-id / --model / --mode
headless runner flags (companion change to the harness repo).

Implements:
- core.Agent / core.AgentSession (one-shot headless per Send)
- core.ModelSwitcher (/model): reads dsh settings.yaml model catalog,
  passes --model to the next run
- core.ModeSwitcher (/mode): read-only | workspace-write |
  danger-full-access | confirm, pinned via --mode
- WorkspaceAgentOptionSnapshotter, MemoryFileProvider, SkillProvider,
  AgentDoctorInfo, session listing
- plugin_agent_dsh.go (build tag !no_dsh), Makefile ALL_AGENTS, config
  example
- Send now drives dsh headless in --jsonl mode: text/thinking deltas, tool
  calls and results stream to the chat, and approval requests (confirm mode
  write/execute interception + workspace-write sandbox escalations) render
  as permission cards answered via the runner's stdin.
- RespondPermission writes the human's decision back to the dsh process.
- /session shows dsh's real session titles (last session/title event,
  fallback first user message) parsed from the zstd-compressed session
  logs (new dep: klauspost/compress).
@baizhu945
baizhu945 requested a review from chenhg5 as a code owner August 14, 2026 13:16
- errcheck: check s.Close() in tests
- unused: drop the unused forceKillCmd helpers and the resultSent
  struct field (Send uses a local flag)
cmd.Wait() closes the stdout pipe; waiting on it before the reader
goroutine finishes could discard the JSONL lines still buffered in the
pipe (tool calls, result/done envelopes), silently dropping events under
race-detector scheduling. The reader now drains to EOF first (the
process closing its write end), then Wait reaps the child.
@baizhu945

Copy link
Copy Markdown
Contributor Author

CI is fully green ✅ (lint, unit-test with -race, smoke, regression, performance).

One extra fix landed during review: drain stdout to EOF before cmd.Waitcmd.Wait() closes the stdout pipe, and waiting on it before the reader goroutine finished could discard JSONL lines still buffered in the pipe (tool calls, result/done envelopes), silently dropping streamed events under race-detector scheduling. The race-detector CI run caught it; the reader now drains to EOF first, then Wait reaps the child.

Local verification summary:

  • golangci-lint run --new-from-rev <base> ./... — no new findings
  • go test -tags no_web ./... (LANG=C) — all packages pass; the single zh_CN-locale-dependent core test (TestRunShellWithProgress_NonexistentCommand) is pre-existing and unrelated (passes with LANG=C)
  • go test -race ./agent/dsh/ — 6/6 stable after the pipe fix
  • go test ./core/ -run TestCUJ — passes

After the turn's done envelope, the dsh process waits for the driver to
close stdin before its approval readline sees EOF and the one-shot exits.
Send held stdin open until cmd.Wait returned, so a completed turn left the
session busy forever (second messages were queued until /stop killed the
process). Close stdin right after the reader drains stdout, before Wait.
@baizhu945

Copy link
Copy Markdown
Contributor Author

One more fix landed after real-world testing: close stdin before cmd.Wait so a completed turn never leaves the session busy.

Symptom: after the first question, the AI finished and replied, but a second message was queued ("will be processed after the current task completes") until /stop was sent. Root cause: after the done envelope the dsh one-shot waits for the driver to close stdin before its approval readline sees EOF and the process exits — while Send held stdin open until cmd.Wait returned, so a finished turn still occupied the session forever.

Fix (with the companion dsh-side change): the reader drains stdout to EOF, then cc-connect closes stdin before cmd.Wait, and the dsh runner also closes its readline after the turn (belt and suspenders). Verified end-to-end: the spawned process now exits promptly after done with stdin held open, and consecutive turns run back-to-back without /stop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant