Skip to content

feat(bindings): add ACP thread binding adapter for WeChat (fixes #55)#56

Open
lyfuci wants to merge 4 commits intoTencent:mainfrom
lyfuci:feat/acp-thread-bindings
Open

feat(bindings): add ACP thread binding adapter for WeChat (fixes #55)#56
lyfuci wants to merge 4 commits intoTencent:mainfrom
lyfuci:feat/acp-thread-bindings

Conversation

@lyfuci
Copy link
Copy Markdown

@lyfuci lyfuci commented Apr 11, 2026

Summary

Adds ACP thread binding support to the WeChat channel so
sessions_spawn(runtime="acp", mode="session", thread=true) actually works
instead of failing with thread_binding_invalid: Thread bindings are unavailable for openclaw-weixin.

Closes #55.

Why

Today every sessions_spawn(runtime="acp") from a WeChat conversation has to
fall back to mode="run" (one-shot). Each call spawns a fresh Claude Code /
Codex / Gemini CLI process, runs one turn, and loses all state:

  • 10-20s cold start per turn
  • No memory between turns — Claude Code can't iterate on its own work
  • Multi-step tasks must be crammed into one huge prompt
  • Orphan harness processes sometimes linger after acpx closes the ACP
    connection

The fix is to register a SessionBindingAdapter for the WeChat channel so
persistent ACP sessions can be tied to a WeChat 1:1 conversation, matching
what Telegram / Discord / LINE / iMessage / BlueBubbles already do.

What's in the change

Four commits, each scoped so reviewers can read them independently:

  1. chore(deps): bump openclaw devDep to ^2026.4.9
    The ChannelPlugin.conversationBindings seam was added after 2026.3.23,
    so typecheck needs a newer dev dependency. Runtime host requirements are
    unchanged (still governed by openclaw.plugin.json minHostVersion).

  2. test(pairing): also mock openclaw/plugin-sdk/infra-runtime subpath
    Pre-existing test regression exposed by the dev dep bump.
    pairing.ts imports withFileLock from the infra-runtime subpath, but
    pairing.test.ts only mocked the root openclaw/plugin-sdk barrel. With
    2026.4.x the subpath is a distinct module graph node so the root mock no
    longer intercepts. One-line fix: add a parallel vi.mock() for the
    subpath. Not a behavior change — just a test infrastructure fix for
    the bump.

  3. test(runtime): add unit coverage for weixin runtime singleton helpers
    src/runtime.ts was previously at 0% coverage. Added 6 thin unit tests
    for setWeixinRuntime / getWeixinRuntime / waitForWeixinRuntime /
    resolveWeixinChannelRuntime. This lifts baseline coverage enough to
    absorb the new file in commit 4 without tripping the 90% threshold.

  4. feat(bindings): add ACP thread binding adapter for WeChat
    The actual feature:

    • src/thread-bindings.ts (~580 LOC) — per-account binding manager +
      SessionBindingAdapter + persistent JSON store + 60s idle/max-age
      sweeper. Structurally a slimmed-down port of
      extensions/telegram/src/thread-bindings.ts from the openclaw
      monorepo, with forum-topic / group handling removed.
    • src/thread-bindings.test.ts (~400 LOC, 27 tests) — bind / resolve /
      touch / unbind adapter flow, metadata merge, persistent store load /
      reload, version rejection, bad-entry rejection, sweeper expiry, sweeper
      no-op on non-expired records.
    • src/channel.ts — import createWeixinThreadBindingManager, eagerly
      initialize it in gateway.startAccount (once per account), and add the
      conversationBindings block to weixinPlugin so the openclaw ACP
      runtime discovers the adapter.

Design decisions (call out for the reviewer)

  • Conversation id = <from_user_id> (e.g. xxx@im.wechat) scoped per
    bot accountId.
    This matches how weixinMessageToMsgContext already
    routes inbound traffic — From, To, and OriginatingTo are all set to
    from_user_id, and group_id is defined in WeixinMessage but never
    consumed anywhere in the plugin. Treating the plugin as 1:1-only keeps
    this PR small. If group / chat-room support lands later, the adapter
    should learn a child placement that maps to a room-level conversation
    id, similar to Telegram's forum topics.
  • Persistent store lives under $STATE_DIR/openclaw-weixin/ thread-bindings-<accountId>.json, using the existing
    writeJsonFileAtomically helper from openclaw/plugin-sdk/json-store to
    avoid torn writes. Load errors are downgraded to logVerbose so a
    corrupt file doesn't brick the plugin.
  • Default idle timeout 24h, no max age cap — identical to Telegram's
    DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS / MAX_AGE_MS, so operators
    who already know Telegram get the same behavior.
  • Only the current placement is advertised in
    SessionBindingAdapter.capabilities.placements. The bind method
    rejects placement: "child" requests outright rather than silently
    treating them as current.

Out of scope (happy to follow up)

  • setIdleTimeoutBySessionKey / setMaxAgeBySessionKey
    conversationBindings accepts these as optional hooks and Telegram wires
    them to runtime knobs. Left out to keep the PR focused; trivial to add
    once the approach here is accepted.
  • Farewell-on-unbind message delivery (Telegram sends a "session closed"
    hint to the chat). WeChat delivery semantics are different enough that
    this warrants a separate design conversation.
  • Group / chat-room conversation binding. Depends on the broader question
    of whether openclaw-weixin will handle group_id traffic at all.

Test plan

  • npm run typecheck
  • npm test — 370 tests passing, coverage 91.38% lines / 90.1% branches
    / 96.29% funcs (above the repo's 90% threshold)
  • npm run build
  • Live round-trip (verified separately on a local gateway): can spawn a
    persistent ACP Claude Code session from a WeChat conversation with
    mode="session", thread=true without hitting
    thread_binding_invalid, and a follow-up sessions_send is routed
    back to the same harness session.

One pre-existing unrelated note:

  • src/auth/pairing.test.ts was failing on main immediately after the
    openclaw dev dep bump due to a mock subpath mismatch (see commit 2). I
    included a single-line fix for it so CI stays green. If you would rather
    land that separately I'm happy to cherry-pick it into its own PR.

lyfuci added 4 commits April 12, 2026 00:58
The ChannelPlugin type in 2026.3.23 did not yet expose the
conversationBindings seam that the new ACP thread binding adapter
plugs into. Bumping the dev dependency so typecheck can validate the
new wiring. Runtime host requirements are unchanged (the plugin still
follows the openclaw.plugin.json min host version window).
pairing.ts imports withFileLock from the infra-runtime subpath, but the
test only mocked the root `openclaw/plugin-sdk` barrel. With
openclaw 2026.4.x the subpath is a distinct module graph node, so the
root mock no longer intercepts the import and the concurrency-safety
test fails with 'expected spy to be called 1 times, but got 0 times'.

Add a parallel vi.mock() for the subpath so the real file-lock import
path is stubbed regardless of which barrel the production code picks.
runtime.ts was previously uncovered by any test. Add lightweight unit
tests for setWeixinRuntime / getWeixinRuntime / waitForWeixinRuntime /
resolveWeixinChannelRuntime, including the 'no ctx, no global yet,
wait for register' fallback path. Also raises overall branch coverage
ahead of the thread-bindings addition.
Lets `sessions_spawn(runtime="acp", mode="session", thread=true)`
actually work on the WeChat channel instead of failing with
`thread_binding_invalid: Thread bindings are unavailable for
openclaw-weixin`.

What this unlocks:

- Persistent multi-turn Claude Code / Codex / Gemini CLI sessions bound
  to a WeChat conversation. Follow-up prompts can now reuse the same
  ACP session via sessions_send instead of cold-starting a fresh
  harness every turn.
- The runtime picks up the adapter via `conversationBindings` on the
  weixin channel plugin; the openclaw ACP runtime dispatches bind /
  resolve / touch / unbind through it the same way it does for
  Telegram, Discord, LINE, iMessage, and BlueBubbles.

Implementation notes:

- src/thread-bindings.ts is a slimmed-down port of the Telegram adapter
  in openclaw/openclaw (extensions/telegram/src/thread-bindings.ts).
  Group / forum topic handling is removed because openclaw-weixin
  currently treats all inbound traffic as 1:1 direct chat; only the
  `current` placement is advertised.
- Conversation ids are just the sender's WeChat id (`xxx@im.wechat`)
  scoped per bot accountId. This matches the existing messaging layer,
  which uses `from_user_id` as both From and To.
- Binding state persists to
  $STATE_DIR/openclaw-weixin/thread-bindings-<accountId>.json via
  writeJsonFileAtomically, and a 60s sweeper removes idle- or
  max-age-expired bindings. Both defaults mirror Telegram's
  (24h idle timeout, no max age cap).
- Eager per-account init hooks in startAccount so the persistent store
  is loaded and the sweeper starts before any inbound traffic arrives.
  The conversationBindings.createManager hook dedupes subsequent
  framework-driven calls to the same manager instance.

Coverage:

- src/thread-bindings.test.ts adds 27 unit tests covering the adapter's
  bind / resolve / touch / unbind flows, metadata merge semantics,
  on-disk store load/reload, version / bad-entry rejection, idle-expiry
  sweeping, and the 'non-expired bindings are left alone' path.
- All existing tests still pass; overall vitest coverage stays above
  the repo's 90% line / branch / statement threshold.

Remaining work for a follow-up (called out so reviewers know what is
intentionally out of scope):

- `setIdleTimeoutBySessionKey` / `setMaxAgeBySessionKey` are not
  wired into conversationBindings. Plumbing those through matches
  Telegram's runtime knob but was skipped here to keep the PR focused
  on the 'make the feature available at all' path.
- A 'farewell message on unbind' hook is not implemented; WeChat
  delivery semantics are different from Telegram's and warrant a
  separate design conversation with maintainers.
- Group / chat-room awareness: if openclaw-weixin grows first-class
  group support, the adapter should learn a `child` placement that
  maps to room-level conversation ids.
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.

[Feature] Support ACP thread binding so mode="session" works on WeChat channel

1 participant