Skip to content

feat(channel): add Personal Weixin iLink channel extension - #3184

Open
DennisWLX wants to merge 9 commits into
agentscope-ai:mainfrom
DennisWLX:codex/feat-personal-weixin-channel
Open

DennisWLX wants to merge 9 commits into
agentscope-ai:mainfrom
DennisWLX:codex/feat-personal-weixin-channel

Conversation

@DennisWLX

@DennisWLX DennisWLX commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

AgentScope-Java Version

2.0.3-SNAPSHOT (main @ ca52405)

Description

Fixes #3183

Adds agentscope-extensions-channel-weixin, a native Java Channel extension for Tencent's
official iLink personal Weixin API, at the same scope and layout as the existing DingTalk,
Feishu, WeCom, GitHub, and GitLab extensions.

This is part 1 of the two-part split described in the issue. The extension is self-contained: it
carries no agentscope-service dependency and can be consumed by any Java host. The AgentScope
service and console integration (control-plane connection and login flows, Scheduler adapters, and
the QR connection page) follows in a separate PR that depends on this module being merged.

What was added

  • WeixinChannel — the standard Channel implementation for direct text conversations:
    long polling, get_updates_buf cursor persistence, context_token replies, and direct-text
    inbound mapping. WeixinChannel.fromProperties(...) covers standalone use and
    WeixinChannel.create(...) takes host-supplied seams.
  • WeixinLoginClient — stateless QR login (start/poll/verify), including the
    scaned_but_redirect host switch. Each call returns a portable WeixinLoginSession, so an
    attempt can be resumed by another process without carrying the QR image along.
  • Account-scoped leases with at-least-once inbox processing — one active consumer per account;
    standby instances keep trying to acquire a released lease; a lease lost mid-dispatch cancels the
    local subscription and suppresses the late reply instead of sending it. Inbound messages without
    a provider message_id are keyed by a payload digest so a repeated batch is still deduplicated.
  • WeixinCredentialProvider, WeixinStateStore, WeixinRuntimeListener — the neutral seams a
    managed host implements. The state contract covers batch acceptance, message claims, context
    tokens, and lease fencing in one place.
  • InMemoryWeixinStateStore is the reference implementation of that contract for standalone
    use and tests, and the README says plainly what it is not: cursor, peer context tokens and
    leases live in one JVM, so a restart replays or drops messages and a second instance cannot
    see the first. WeixinChannel.fromProperties(...) warns on construction for that reason. A
    durable store is the host's to implement; the module ships no Redis or JDBC adapter.
  • WeixinEndpointPolicy — the allowlist applied before credentials are sent: https (or loopback
    http), official *.weixin.qq.com hosts on port 443, no userinfo/query/fragment/path, and
    redirects only within the official provider, between loopback endpoints, or to the same host.
  • No new third-party dependencies; the protocol uses the JDK HTTP client and tests use the JDK
    HttpServer.

iLink -14 is surfaced as a credential-rejection observation rather than a product decision: the
host decides whether that means reauthorization, a notification, or something else. The module
carries no owner or vault references, no persistence policy, no public routes, and no console
state.

Because this drives a personal account rather than an enterprise bot, the README opens with a
Compliance and scope section: personal-account automation is governed by the provider's terms
and the operator's own policy, the account can be restricted by the provider, and the host owns
the decision to deploy it.

How to test

  • mvn -pl agentscope-extensions/agentscope-extensions-channel/agentscope-extensions-channel-weixin -am test
    — 50 tests, 1 skipped (WeixinLiveSmokeTest is gated behind WEIXIN_LIVE_TEST=true).
    • WeixinChannelLoopbackTest — round trip through a fake Gateway and an embedded iLink server:
      cursor, context_token, reply routing, and recovery when cursor persistence is interrupted.
    • WeixinChannelRuntimeTest — agent-initiated deliver(...), delivery and polling credential
      rejection, polling failure retry, messages without a provider id, peer throttling, context
      fencing, listener isolation, and the stopped-channel guard.
    • WeixinLeaseLifecycleTest — lease takeover, renewal while a turn is running, and lease loss
      cancelling dispatch.
    • WeixinStateStoreTest — claim and lease fencing, automatic expiry of completed tombstones,
      and eviction of accounts that went idle, plus the guarantee that read-only lookups do not
      retain an account.
    • WeixinLoginClientTest — protocol headers, the redirect host switch, the verify-code step
      (blank code rejected, code carried in the status query, need_verifycode surfaced) and
      rejection of an untrusted redirect_host.
    • WeixinOutboundClientTest, WeixinChannelTest, WeixinEndpointPolicyTest — outbound -14
      mapping, the notify start/stop endpoints, secret redaction, and the endpoint allowlist
      (12 cases).
  • Line coverage for the module is 93%; WeixinChannel is at 92%. What remains uncovered is
    hostile-store error injection (a store that throws from renewLease/isLeaseCurrent/
    releaseLease) and the unreachable NoSuchAlgorithmException branch of the digest helper.
  • WeixinLiveSmokeTest covers a real QR scan and two-way text through Tencent iLink. Credentials
    never touch disk.

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (module README)
  • Code is ready for review

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

First review of the new agentscope-extensions-channel-weixin module (+3129 across 31 files). Overall quality is well above the average contribution here: the credential types redact themselves in toString() (and that is asserted in tests), all error paths log through safeMessage(...), the endpoint policy is a real allowlist rather than a permissive check, lease handling uses generation counters, the live smoke test is gated behind WEIXIN_LIVE_TEST=true, and both the aggregator pom and the BOM were updated. Nothing here is a correctness bug, so this is a COMMENT rather than REQUEST_CHANGES — the two things I would like settled before merge are the durability story of the state store and an explicit ToS/brand-compliance note for a personal-account channel.

Findings

  • [Warning] InMemoryWeixinStateStore.java:44 — account entries are created on demand and never evicted (unbounded growth)
  • [Info] InMemoryWeixinStateStore.java:32 — cursor / peer tokens / lease are memory-only: restart replays or drops inbound messages and loses the conversation token
  • [Info] WeixinEndpointPolicy.java:84 — loopback is matched by hostname string, so a hostname resolving to 127.0.0.1 passes; a literal isLoopbackAddress() check would close it
  • [Info] WeixinChannel.java:153 — please add an explicit note that this drives a personal WeChat account over an unofficial iLink endpoint, so maintainers can judge the compliance risk

Notes

  • CLA status was not found on head 16da1747; please make sure the CLA check is green — I cannot approve while it is unresolved.
  • The sibling PRs #3180/#3182 touch the same channel abstraction; a rebase check after those land would be worthwhile.

Automated review by github-manager-bot

@DennisWLX
DennisWLX force-pushed the codex/feat-personal-weixin-channel branch from 31ba755 to f3ab33c Compare September 17, 2026 06:00

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-checked the new head (f3ab33c7). The delta since the last round is test-only: one new WeixinChannelRuntimeTest covering six runtime paths the loopback fixture did not reach. That is a real improvement — but none of the three findings from the previous review were touched, so this PR is still not approvable as it stands.

What the new test does well

deliversWithTheContextTokenStoredForThePeer, dispatchesMessagesWithoutAProviderIdExactlyOnce, peerThrottleStopsDispatchAndSurfacesATransientFailure, pollingCredentialRejectionStopsTheChannel, reportsCredentialRejectionFromDeliveryAndStopsTheChannel and rejectsDeliveryWithoutAnActiveConsumer assert on the wire payload (to_user_id, context_token) rather than on internal state, and the throttle test pins the exact boundary (21st event must not dispatch) instead of "eventually failed". @Timeout(60) on the class plus a real HttpServer on an ephemeral port means these will not silently hang CI. Credential-rejection propagation to WeixinRuntimeListener was previously untested behaviour, so this closes a genuine gap.

Still open (unchanged from the previous round)

No main-source file changed, so all three remain:

  • WeixinEndpointPolicy.isLoopback matches on hostname string — a host named localhost.* that resolves to 127.0.0.1 passes the SSRF gate. Resolve to an address and check isLoopbackAddress() instead.
  • InMemoryWeixinStateStore.accounts grows per distinct accountId with no eviction, and the map is also used as a validation path.
  • Inbox cursor, per-peer context tokens and the lease live only in this JVM, so a restart re-reads the personal-message backlog from a null cursor.

The first one is the blocker; the other two are acceptable if documented as single-instance scope, which they currently are not.

CI green (6/6 SUCCESS) and CLA signed on this head.


Automated review by github-manager-bot

@oss-maintainer

Copy link
Copy Markdown
Collaborator

Correction to the review comment above: I wrote that WeixinChannelRuntimeTest covers "six runtime paths" — it actually has ten @Test methods. The four I missed (pollingFailuresAreRetriedAndReported, fencesMessagesWhenContextBookkeepingLosesTheLease, listenerFailuresDoNotStopDispatch, stoppedChannelCannotRestart) are the strongest ones in the file, in particular the lease-fencing case asserting that a lost saveContextToken fences dispatch before the agent sees the message. Understated rather than overstated, so the assessment stands: test-only delta, prior findings untouched.


Automated review by github-manager-bot

DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 17, 2026
Follow-up to the review on agentscope-ai#3184.

- `InMemoryWeixinStateStore` no longer creates accounts from read-only lookups
  (`loadCursor`, `loadContextToken`, `isLeaseCurrent`, `releaseLease`, claim validation),
  and every accepted batch now expires completed tombstones across all accounts and forgets
  accounts whose lease expired with an empty inbox. The javadoc states that this adapter is
  single-JVM state and not for production.
- `WeixinChannel.fromProperties(...)` warns on construction that the standalone factory keeps
  credentials and runtime state inside that process.
- The module README gains a "Compliance and scope" section (personal account, provider terms,
  host responsibility) and spells out what the in-memory store does and does not provide.

The loopback claim in the review is not reproducible: loopback is matched on the literal host
(`localhost`/`127.0.0.1`/`::1`), so `localhost.attacker.example` is rejected at
`normalizeBaseUrl` and never reaches `validateProviderEndpoint`. Resolving the host instead would
let such a name *into* the allowlist, so the literal match is kept and a regression test now pins
the rejection.

Also covers the remaining functional gaps: `WeixinLoginClient.verify(...)` (blank code, code in
the status query, the verification challenge, untrusted redirect, provider failures) and the
outbound `notifyStart`/`notifyStop` endpoints. Module line coverage is 93%.
@DennisWLX

Copy link
Copy Markdown
Contributor Author

Follow-up to the review above (commit d88e133).

Addressed

  • Unbounded account growth in InMemoryWeixinStateStore — read-only lookups no longer create accounts, and accepted batches now expire tombstones across all accounts and forget idle ones.
  • Durability — README now states the exact losses and what a durable store buys; the standalone factory warns on construction; the store javadoc leads with "not for production".
  • Compliance note — new "Compliance and scope" section in the module README.
  • The remaining functional test gaps the review did not list: WeixinLoginClient.verify(...) and the outbound notifyStart/notifyStop endpoints.

Not changed, with reasoning

  • The WeixinEndpointPolicy loopback finding did not reproduce (localhost.attacker.example is already rejected); resolving the host instead would add the hole. A regression test now pins the current behaviour. Details in the inline reply.

On your notes

Tests: 50 in the module (1 environment-gated live smoke test skipped), module line coverage 93%.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-checked head d88e1330 (fix(weixin): bound in-memory state and document deployment limits). Two of the three findings from the previous round are properly fixed, one of my earlier findings was wrong and I want to correct it on the record, and the new commit surfaces one issue I would like addressed before this is approvable.

Correction — the SSRF finding was a false positive. I claimed WeixinEndpointPolicy.isLoopback matched on a hostname prefix, so localhost.attacker.example could pass the gate. Reading WeixinEndpointPolicy.java:82-84, the check is an exact match on localhost / 127.0.0.1 / ::1 — no suffix can sneak through. The new rejectsHostnamesThatOnlyLookLikeLoopback test pins exactly that, including the 127.0.0.1.attacker.example case. Please treat that earlier comment as retracted; my mistake.

Fixed as requested:

  • InMemoryWeixinStateStore now splits account(...) (mutating) from existing(...) (read-only lookups), so loadCursor / loadContextToken / isLeaseCurrent / releaseLease no longer materialise state, and readOnlyLookupsDoNotRetainAccounts asserts retainedAccounts() == 0. Tombstone expiry moved into a single prune(...) that also forgets idle accounts.
  • Single-instance scope is now documented rather than implied: the class javadoc says "Not for production", the README explains what a host must implement for restart recovery and horizontal scaling, and fromProperties logs a startup warning. That is the outcome I was asking for.

New blocker (correctness, see inline): the eviction rule in prune removes the whole Account, cursor included, so a peer idle for longer than the lease TTL restarts reading from "". That reintroduces the backlog-replay path the cursor exists to prevent, and it conflicts with the README's "reference implementation" framing.

Security item to resolve (see inline): verify_code and qrcode travel as GET query parameters (WeixinLoginClient.observe), and the new test asserts that wire shape. Anything that logs request URIs — provider access logs, intermediaries, jdk.httpclient debug logging, an exception message carrying the URI — will capture a login secret. The related bot_token handling in the same method is worth a leak check on the error path.

CI is green on this head (Check License, Module Sync, ubuntu/windows builds, codecov/patch). I could not find a license/cla status context on d88e1330, so I am not treating CLA as satisfied — if your CLA is tracked another way here, please point me at it. Approving is blocked on the eviction item; the query-string item may be acceptable with a documented justification if the provider protocol forces GET.


Automated review by github-manager-bot

DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 17, 2026
Follow-up to the second review round on agentscope-ai#3184.

**Correctness — the eviction rule dropped the cursor.** `prune` forgot any account whose lease
had expired, cursor included, so an account idle for longer than the lease TTL would have resumed
from `""` and replayed the provider backlog. The automatic sweep now only forgets accounts that
hold nothing at all (no cursor, no peer context, no inbox, no live lease) — the ids materialised
by validation paths that the previous commit stopped creating in the first place. Retiring real
state is now an explicit host action: `WeixinStateStore.removeAccount(accountId)` (default no-op,
implemented by the in-memory store), so a host can drop an account without the store guessing.
`keepsTheCursorWhenAnAccountGoesIdle` replaces the assertion that used to demand the opposite.

**Security — the login transport is query-string based.** The provider protocol puts `qrcode`
and `verify_code` in the GET status query, so the status URL is a credential; that is now stated
in the README with the operational consequence (no URI-level logging behind this client). The
module was verified not to echo anything itself: provider failures report only the status code,
and both JSON parse paths (login and outbound) now report line/column instead of Jackson's
default message, which quotes the offending input — a partially parsed login response contains
`bot_token`, and an inbox response contains message payloads and context tokens. Three tests
assert that an HTTP failure, an unparseable login body and a transport failure carry neither the
verify code, the QR id, nor body content.

Not changed: `fromProperties` still logs WARN on every construction — that is the intent, and
`create(...)` with an explicit store is the silent path.
DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 17, 2026
Follow-up to the second review round on agentscope-ai#3184.

**Correctness — the eviction rule dropped the cursor.** `prune` forgot any account whose lease
had expired, cursor included, so an account idle for longer than the lease TTL would have resumed
from `""` and replayed the provider backlog. The automatic sweep now only forgets accounts that
hold nothing at all (no cursor, no peer context, no inbox, no live lease) — the ids materialised
by validation paths that the previous commit stopped creating in the first place. Retiring real
state is now an explicit host action: `WeixinStateStore.removeAccount(accountId)` (default no-op,
implemented by the in-memory store), so a host can drop an account without the store guessing.
`keepsTheCursorWhenAnAccountGoesIdle` replaces the assertion that used to demand the opposite.

**Security — the login transport is query-string based.** The provider protocol puts `qrcode`
and `verify_code` in the GET status query, so the status URL is a credential; that is now stated
in the README with the operational consequence (no URI-level logging behind this client). The
module was verified not to echo anything itself: provider failures report only the status code,
and both JSON parse paths (login and outbound) now report line/column instead of Jackson's
default message, which quotes the offending input — a partially parsed login response contains
`bot_token`, and an inbox response contains message payloads and context tokens. Three tests
assert that an HTTP failure, an unparseable login body and a transport failure carry neither the
verify code, the QR id, nor body content.

Not changed: `fromProperties` still logs WARN on every construction — that is the intent, and
`create(...)` with an explicit store is the silent path.
@DennisWLX
DennisWLX force-pushed the codex/feat-personal-weixin-channel branch from 981a623 to d4b8082 Compare September 17, 2026 08:18
@DennisWLX

Copy link
Copy Markdown
Contributor Author

Follow-up to the second review round (commit d4b8082).

Blocker fixed — the eviction rule was dropping the cursor. You were right and it was my bug: prune forgot any account whose lease had expired, cursor included, so an account quiet for longer than the lease TTL would have resumed from "". The sweep now only forgets accounts that hold nothing at all (no cursor, no peer context, no inbox, no live lease), which still reclaims the ids the original finding was about, and retiring real state is an explicit WeixinStateStore.removeAccount(accountId) (default no-op) rather than a guess. keepsTheCursorWhenAnAccountGoesIdle replaces the assertion that demanded the opposite.

Security item addressed. The status URL is credential material by protocol (get_qrcode_status is a GET and verify_code rides along as a query parameter — there is no documented body variant, so it stays there). What is now guaranteed in code: the class javadoc names the leak paths, the README has an operator-facing "Login transport" section, provider failures report status codes only, and both JSON parse paths report line/column instead of Jackson's default message, which quotes the input — bot_token on the login side and inbox payloads plus context tokens on the outbound side. Four tests assert no secret reaches an exception message over HTTP failure, unparseable body, or transport failure.

On your CLA note — you were right and I was wrong. I said earlier the check was green on d88e1330; it was green on f3ab33c7 (status emitted 06:00:31Z) and was never emitted for d88e1330. Nothing was re-evaluated on the newer heads. Posting this comment should make cla-assistant re-run; if the status still does not appear, I will ask a maintainer to re-trigger it rather than leave it ambiguous.

Tests: 56 in the module (1 environment-gated live smoke test skipped). CI on d4b8082 is running.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a Personal Weixin iLink channel module with QR login, endpoint allowlisting, long-poll ingestion, cursor/inbox state, context-token replies, and account leases. Verdict: REQUEST_CHANGES, because the id-less message fallback can permanently drop repeated inbound messages; startup retry and provider-success validation also need correction. Strengths include careful credential redaction, endpoint restrictions, and a well-defined durable-state/lease contract.


Automated review by github-manager-bot

DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 17, 2026
…utcomes

Addresses the CHANGES_REQUESTED review on agentscope-ai#3184.

- **`longPollTimeoutMs` did nothing.** Every request used `requestTimeoutMs`, so a deadline shorter
  than the provider's long-poll window aborted every `getupdates` call. The client now derives the
  deadline per call: `longPollTimeoutMs` plus a short grace for `getupdates`, `requestTimeoutMs`
  for the control calls. `longPollSettingKeepsASlowGetUpdatesAlive` drives a 900 ms response with a
  300 ms request timeout and asserts it still succeeds.
- **A failed `notifystart` left the account polling.** The catch reported a transient failure and
  fell through into the poll loop with `providerStarted == false`, so with the lease still
  renewing the session was never started again and no `notifystop` was ever owed. The session now
  leaves the lease, so the outer loop backs off, reacquires and retries startup.
  `providerSessionStartIsRetriedAfterATransientFailure` fails `notifystart` twice and asserts the
  third attempt happens. This fix also exposed that `WeixinChannelLoopbackTest`'s fake provider had
  never implemented `notifystart` — it was returning 404 and the old code swallowed it — so the
  fixture now answers it.
- **A missing provider outcome counted as success.** `asInt(0)` accepted `{}` or any 2xx body
  without `ret`/`errcode` as a delivered message, after which the inbox claim completed and the
  reply was dropped. Both `updates(...)` and `assertSuccess(...)` now require an explicit provider
  result. `responsesWithoutAProviderOutcomeAreRejected` covers poll and send. Provider outcome and
  credential failures also keep their exception type instead of being wrapped in a generic
  `RuntimeException` by `sendWithContext`.
- **Byte-identical id-less messages collapsed into one.** The fallback inbox id was the payload
  digest alone, so two identical messages in one batch became one claim while the cursor advanced —
  a user typing the same thing twice lost one message. The fallback id now includes the position in
  the batch, which keeps a re-delivered batch deduplicated while separating genuine duplicates.
  `byteIdenticalMessagesWithoutIdsAreBothDispatched` covers it.

Test-design fix along the way: the runtime fixture now emulates a long poll (25 ms) instead of
answering instantly, which was spinning the consumer loop and starving the rest of the suite; the
lease-lifecycle suite dropped from 17.8 s to 7.0 s.
@DennisWLX

Copy link
Copy Markdown
Contributor Author

All four findings from the CHANGES_REQUESTED review are fixed in ea8c4fd. Every one of them was real, and two were bugs I introduced in the previous round.

Finding Fix
longPollTimeoutMs had no effect timeoutFor(path) derives the deadline per call: long poll window + grace for getupdates, requestTimeoutMs for the control calls
A failed notifystart left the account polling forever the session now leaves the lease so the outer loop backs off and retries startup
A missing provider outcome counted as success both updates(...) and assertSuccess(...) require an explicit ret/errcode before acknowledging
Byte-identical id-less messages collapsed into one claim the fallback id is payload-<position>-<digest>, so repeats in a batch survive while a re-delivered batch stays deduplicated

Two things surfaced while fixing them, both worth the reviewer's attention:

  1. The test suite was relying on the notifystart bug. WeixinChannelLoopbackTest's fake provider never implemented that endpoint — it answered 404 and the old code swallowed it and polled anyway. Fixing the retry path made the loopback test fail immediately, which is how I found it. The fixture now answers it.
  2. sendWithContext was hiding failure types. Provider-outcome and credential failures were both wrapped in a generic RuntimeException("Weixin send failed"); they now propagate with their own type.

Test-design fix along the way: the runtime fixture answered getupdates instantly, which spun the consumer loop and starved the rest of the suite — it now emulates a long poll. The lease-lifecycle suite went from 17.8 s to 7.0 s and stopped flaking under load.

Module: 60 tests (1 environment-gated live smoke test skipped). Ready for re-review.

@DennisWLX

Copy link
Copy Markdown
Contributor Author

Note on head 80cfaf37: it is an empty commit, pushed only to re-trigger CI.

The previous run failed on Windows in a module this PR does not touch:

JdkHttpTransportTest.testStreamErrorResponseWithEmptyBody
Stream timeout: Did not observe any item or terminal signal within first signal from a Publisher
Cannot invoke "java.lang.Integer.intValue()" because HttpTransportException.getStatusCode() is null

agentscope-core failed first, so every later module — including this one — was skipped and never built. The same head shape passed on both runners one commit earlier. I could not re-run the jobs myself (rerun-failed-jobs returns 403 without admin rights), so the retrigger commit is the available option. Happy to drop it if you would rather re-run from your side.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Thanks for the quick turnaround — this push resolves four of the issues from the previous review, each with a regression test: longPollTimeoutMs now actually bounds the getupdates request, a missing ret/errcode is no longer treated as a delivered message, a failed notifystart is retried instead of silently polling, and byte-identical id-less messages in one batch no longer collapse into a single inbox claim. The new commits look correct to me.

Findings

  • [Warning] WeixinChannel.java:289 — startup retries run at a fixed 1000 ms because backoff is only escalated inside the polling loop; a long provider outage turns into an unbounded fast retry loop.
  • [Warning] WeixinOutboundClient.java:89 — the widened catch now also lets local IllegalStateException signals (fenced lease, non-2xx HTTP) escape unwrapped, and the redundant WeixinCredentialRejectedException alternative in the union suggests the type distinction was not intended here.
  • [Info] WeixinOutboundClient.java:104 — the provider's advertised longpolling_timeout_ms is parsed but never used, so a provider window longer than the local setting still gets aborted early.

Not touched by this push

The earlier findings on InMemoryWeixinStateStore (unbounded per-account map growth / cursor lost on restart), WeixinEndpointPolicy.isLoopback hostname matching, and the verification secrets carried in the WeixinLoginClient query string are still open — no need to fix them in this PR if they belong to the follow-up in this stack, but they should stay tracked.

Overall: good progress, the two warnings are worth a look before merge.


Automated review by github-manager-bot

notifyListener(
"transient failure",
listener -> listener.onTransientFailure(p.accountId(), safeMessage(error)));
// The provider session never started: there is nothing to poll and nothing to stop.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrying notifystart is the right fix, and the providerStarted guard in the finally block correctly avoids stopping a session that never started. One gap left: this throw exits consume() before the polling loop ever raises backoff, so poll()'s catch keeps sleeping the same 1000 ms forever. A provider that is down for an hour costs ~3600 notifystart attempts and ~3600 warn lines, while the polling path behind it escalates to maxBackoffMs. Growing the backoff here (or in the poll() catch) would keep both startup and polling retries on the same escalating policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and it is a real gap in what I wrote: backoff is escalated only in the polling loop's catch, so the startup throw routes to poll()'s catch which sleeps the field without growing it. A provider that is down during startup therefore retries at a flat 1 s — same cost profile as the loop before I moved the retry out of it. The fix is to escalate in the outer catch too (capped by maxBackoffMs), so startup and polling share one policy.

Tracking it as a follow-up item rather than pushing another head onto an approved PR; happy to land it here instead if you would prefer it before merge.

post(
"/ilink/bot/sendmessage",
Map.of("msg", m, "base_info", baseInfo()),
beforeSend));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things worth separating here. WeixinCredentialRejectedException extends RuntimeException, not IllegalStateException, so listing it in this union is a no-op — it was already excluded by the dedicated catch in consume(). The side effect that does change is that every IllegalStateException now escapes unwrapped, including the ones this class produces for local fencing: beforeSend.run() throws IllegalStateException("Weixin lease lost"), and post() throws IllegalStateException("iLink HTTP " + status). Those are not provider outcomes, so the commit message's intent ("provider outcome … keep their type") is only partly met, and drainInbox()'s catch (Exception) will call failMessage() for a claim whose lease has just been fenced. A dedicated exception type for "no ret/errcode" would let this catch stay narrow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points are correct.

WeixinCredentialRejectedException extends RuntimeException, so that alternative in the multi-catch is dead — it was matched by the earlier dedicated catch already. And the side effect you identified is the real problem: IllegalStateException("Weixin lease lost") from beforeSend.run() and IllegalStateException("iLink HTTP 503") from post() are local signals, not provider outcomes, and they now escape unwrapped. The intent was only to stop wrapping the provider-outcome failure, so a dedicated type (e.g. WeixinProviderRejection extends IllegalStateException) thrown by requireProviderResult(...)/assertSuccess(...), with this catch narrowed to that type plus credential rejection, is the right shape.

On the failMessage concern: validClaim(...) returns null once the lease is not current, so the fenced claim is not resurrected — but calling it at all on that path is still the wrong shape, agreed.

Tracking for the follow-up alongside the backoff item.

}

/** Grace on top of the long-poll window before the request is abandoned. */
private static final long LONG_POLL_GRACE_MS = 5_000L;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeoutFor() derives the long-poll deadline from the configured longPollTimeoutMs plus a fixed 5 s grace, while the getupdates response already carries longpolling_timeout_ms (parsed into JsonNodeResponse at the bottom of this file and never read). If the provider advertises a window longer than the local setting, the client still aborts the poll early — exactly the failure mode this commit set out to remove — and the new test only covers the case where the local setting is the larger one. Taking max(local, advertised) once the response is known, or at least logging when the advertised value exceeds the configured one, would close that gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — longpolling_timeout_ms is parsed onto the response and never read, and my test only covers the case where the local setting is the larger one, so the gap you describe is untested as well as unfixed.

Of the two options, honouring it is the better one: remember the advertised window from the last response and take max(configured, advertised) + grace for the next poll, which is exactly the "provider asks for a longer window" case. Dropping the field would remove state with no effect but would also throw away the provider's own declaration.

Tracking it for the follow-up with a test where the advertised value exceeds the configured one.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-checked the fixes in ea8c4fdb at head 80cfaf37 (the head commit itself is an empty CI retrigger, as you noted). All four blocking findings from the previous round are genuinely closed, and the way they were closed holds up:

  • longPollTimeoutMs now bounds the getupdates request with an explicit 5 s grace on top of the provider window (WeixinOutboundClient:172-177), so the long poll is no longer abandoned mid-window.
  • requireProviderResult(...) on the response path means a missing/failed ret is no longer read as a delivered message.
  • A failed notifystart now throws and lets the outer loop back off, reacquire and retry startup instead of silently polling an unstarted session.
  • Byte-identical id-less messages in one batch get distinct fallback ids, so the inbox claim no longer collapses them — that was real data loss, not just a dedup quirk.

The three items I flagged as "not touched by this push" are also in better shape than my earlier note suggested: InMemoryWeixinStateStore now has completion retention plus removeAccount (:68, :206), and WeixinLoginClient handles the credential-bearing status URL carefully — exceptions carry only the HTTP status, the Jackson failure reports line/column instead of a body snippet, and safeMessage reduces to the exception class name. Good judgment there.

Approving. Three non-blocking notes for the follow-up in this stack:

  1. WeixinChannel:255 — the outer consumer retry sleeps at a fixed backoff because the field is only escalated inside the polling loop and reset at :317, so a provider that keeps failing at notifyStart retries at a steady 1 s cadence, which reads as log spam during a long outage. Waking the field-based backoff in the outer loop (or capping startup retries) would be cleaner; the observable behavior is acceptable as-is.
  2. WeixinOutboundClient:253 — the provider-advertised longpolling_timeout_ms is parsed and carried on the response but never read by WeixinChannel:299, so a provider asking for a longer window than our local setting still gets aborted at ours. Either honour it or drop the field so we are not carrying state with no effect.
  3. WeixinEndpointPolicy:82isLoopback matches only localhost/127.0.0.1/::1, so 127.0.0.2, 127.1 and [::1] (bracketed in a URI host) are rejected. That fails closed rather than open, but it will confuse anyone running the loopback fixture on a non-default 127/8 address.

CI is green (build on ubuntu and windows, license, module sync) and CLA is signed.


Automated review by github-manager-bot

@DennisWLX

Copy link
Copy Markdown
Contributor Author

Thanks for the approval — noted on all three follow-up notes, and I agree with each of them. Disposition, so they do not get lost:

  1. Startup retry keeps a flat 1 s backoff (WeixinChannel:255) — correct: backoff is only escalated in the polling loop's catch and reset on a successful poll, so a provider failing at notifyStart retries at a steady cadence. Fix is to escalate in the outer catch as well, sharing maxBackoffMs. Accepted as non-blocking for now and tracked below.
  2. longpolling_timeout_ms is carried but never read (WeixinOutboundClient:253, also raised inline) — I will honour it rather than drop it: keep the advertised window and use max(configured, advertised) + grace for the next poll, with a test where the advertised value exceeds the configured one. Dropping the field would remove the provider's own declaration.
  3. isLoopback only accepts localhost/127.0.0.1/::1 (WeixinEndpointPolicy:82) — agreed it fails closed, and it will bite anyone pointing the fixture at 127.0.0.2 or a bracketed [::1]. The fix is to widen the literal check to 127.0.0.0/8 plus bracketed IPv6 loopback while keeping it literal, because the earlier finding (a hostname that merely resolves to 127.0.0.1 must not enter) is the reason it is not resolved via InetAddress.

Also accepted from the inline review: a dedicated exception type for the "no ret/errcode" outcome so WeixinOutboundClient's catch stops letting local fencing signals (Weixin lease lost, iLink HTTP 503) escape unwrapped.

I have not pushed these onto this head — the PR is approved and green, and churning it would cost another full CI cycle for changes you have already marked non-blocking. They will land as the first commits of the follow-up work in this stack (#3186), or as a small hardening PR on the extension immediately after this merges, whichever the maintainers prefer. The module's remaining uncovered lines are the same list: hostile-store error injection and the unreachable digest branch.

@Buktal Buktal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 20, 2026
Follow-up to the review on agentscope-ai#3184.

- `InMemoryWeixinStateStore` no longer creates accounts from read-only lookups
  (`loadCursor`, `loadContextToken`, `isLeaseCurrent`, `releaseLease`, claim validation),
  and every accepted batch now expires completed tombstones across all accounts and forgets
  accounts whose lease expired with an empty inbox. The javadoc states that this adapter is
  single-JVM state and not for production.
- `WeixinChannel.fromProperties(...)` warns on construction that the standalone factory keeps
  credentials and runtime state inside that process.
- The module README gains a "Compliance and scope" section (personal account, provider terms,
  host responsibility) and spells out what the in-memory store does and does not provide.

The loopback claim in the review is not reproducible: loopback is matched on the literal host
(`localhost`/`127.0.0.1`/`::1`), so `localhost.attacker.example` is rejected at
`normalizeBaseUrl` and never reaches `validateProviderEndpoint`. Resolving the host instead would
let such a name *into* the allowlist, so the literal match is kept and a regression test now pins
the rejection.

Also covers the remaining functional gaps: `WeixinLoginClient.verify(...)` (blank code, code in
the status query, the verification challenge, untrusted redirect, provider failures) and the
outbound `notifyStart`/`notifyStop` endpoints. Module line coverage is 93%.
DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 20, 2026
Follow-up to the second review round on agentscope-ai#3184.

**Correctness — the eviction rule dropped the cursor.** `prune` forgot any account whose lease
had expired, cursor included, so an account idle for longer than the lease TTL would have resumed
from `""` and replayed the provider backlog. The automatic sweep now only forgets accounts that
hold nothing at all (no cursor, no peer context, no inbox, no live lease) — the ids materialised
by validation paths that the previous commit stopped creating in the first place. Retiring real
state is now an explicit host action: `WeixinStateStore.removeAccount(accountId)` (default no-op,
implemented by the in-memory store), so a host can drop an account without the store guessing.
`keepsTheCursorWhenAnAccountGoesIdle` replaces the assertion that used to demand the opposite.

**Security — the login transport is query-string based.** The provider protocol puts `qrcode`
and `verify_code` in the GET status query, so the status URL is a credential; that is now stated
in the README with the operational consequence (no URI-level logging behind this client). The
module was verified not to echo anything itself: provider failures report only the status code,
and both JSON parse paths (login and outbound) now report line/column instead of Jackson's
default message, which quotes the offending input — a partially parsed login response contains
`bot_token`, and an inbox response contains message payloads and context tokens. Three tests
assert that an HTTP failure, an unparseable login body and a transport failure carry neither the
verify code, the QR id, nor body content.

Not changed: `fromProperties` still logs WARN on every construction — that is the intent, and
`create(...)` with an explicit store is the silent path.
DennisWLX added a commit to DennisWLX/agentscope-java that referenced this pull request Sep 20, 2026
…utcomes

Addresses the CHANGES_REQUESTED review on agentscope-ai#3184.

- **`longPollTimeoutMs` did nothing.** Every request used `requestTimeoutMs`, so a deadline shorter
  than the provider's long-poll window aborted every `getupdates` call. The client now derives the
  deadline per call: `longPollTimeoutMs` plus a short grace for `getupdates`, `requestTimeoutMs`
  for the control calls. `longPollSettingKeepsASlowGetUpdatesAlive` drives a 900 ms response with a
  300 ms request timeout and asserts it still succeeds.
- **A failed `notifystart` left the account polling.** The catch reported a transient failure and
  fell through into the poll loop with `providerStarted == false`, so with the lease still
  renewing the session was never started again and no `notifystop` was ever owed. The session now
  leaves the lease, so the outer loop backs off, reacquires and retries startup.
  `providerSessionStartIsRetriedAfterATransientFailure` fails `notifystart` twice and asserts the
  third attempt happens. This fix also exposed that `WeixinChannelLoopbackTest`'s fake provider had
  never implemented `notifystart` — it was returning 404 and the old code swallowed it — so the
  fixture now answers it.
- **A missing provider outcome counted as success.** `asInt(0)` accepted `{}` or any 2xx body
  without `ret`/`errcode` as a delivered message, after which the inbox claim completed and the
  reply was dropped. Both `updates(...)` and `assertSuccess(...)` now require an explicit provider
  result. `responsesWithoutAProviderOutcomeAreRejected` covers poll and send. Provider outcome and
  credential failures also keep their exception type instead of being wrapped in a generic
  `RuntimeException` by `sendWithContext`.
- **Byte-identical id-less messages collapsed into one.** The fallback inbox id was the payload
  digest alone, so two identical messages in one batch became one claim while the cursor advanced —
  a user typing the same thing twice lost one message. The fallback id now includes the position in
  the batch, which keeps a re-delivered batch deduplicated while separating genuine duplicates.
  `byteIdenticalMessagesWithoutIdsAreBothDispatched` covers it.

Test-design fix along the way: the runtime fixture now emulates a long poll (25 ms) instead of
answering instantly, which was spinning the consumer loop and starving the rest of the suite; the
lease-lifecycle suite dropped from 17.8 s to 7.0 s.
Add `agentscope-extensions-channel-weixin`, a native Java Channel adapter for
Tencent's official iLink personal Weixin API, at the same scope as the existing
DingTalk, Feishu, WeCom, GitHub, and GitLab channel extensions.

The module owns provider protocol and Channel runtime behavior only:

- `WeixinLoginClient` — stateless QR login (`start`/`poll`/`verify`) returning
  a portable `WeixinLoginSession`, so an attempt can be resumed by another
  process without carrying the QR image along.
- Long polling, `get_updates_buf` cursor persistence, `context_token` replies,
  and direct-text inbound mapping.
- Account-scoped leases with at-least-once inbox processing: one active
  consumer per account, standalone instances keep trying to acquire a released
  lease, and a lost lease cancels local dispatch before it can send a late
  reply.
- `WeixinEndpointPolicy` — the endpoint allowlist that runs before credentials
  are sent: https (or loopback http), official `*.weixin.qq.com` hosts on 443,
  and redirects only within the official provider, between loopback endpoints,
  or to the same host.
- Neutral host seams (`WeixinCredentialProvider`, `WeixinStateStore`,
  `WeixinRuntimeListener`) plus `WeixinChannel.fromProperties(...)` for
  standalone use and `WeixinChannel.create(...)` for managed hosts. An
  in-memory state store is included for standalone development and tests.

It carries no AgentScope product concepts: no owner or vault references, no
persistence policy, no public routes, no console state. iLink `-14` is reported
as a credential-rejection observation and the host decides what that means.

Register the module in the channel parent POM, `agentscope-all`, and
`agentscope-bom`.
Follow-up to the review on agentscope-ai#3184.

- `InMemoryWeixinStateStore` no longer creates accounts from read-only lookups
  (`loadCursor`, `loadContextToken`, `isLeaseCurrent`, `releaseLease`, claim validation),
  and every accepted batch now expires completed tombstones across all accounts and forgets
  accounts whose lease expired with an empty inbox. The javadoc states that this adapter is
  single-JVM state and not for production.
- `WeixinChannel.fromProperties(...)` warns on construction that the standalone factory keeps
  credentials and runtime state inside that process.
- The module README gains a "Compliance and scope" section (personal account, provider terms,
  host responsibility) and spells out what the in-memory store does and does not provide.

The loopback claim in the review is not reproducible: loopback is matched on the literal host
(`localhost`/`127.0.0.1`/`::1`), so `localhost.attacker.example` is rejected at
`normalizeBaseUrl` and never reaches `validateProviderEndpoint`. Resolving the host instead would
let such a name *into* the allowlist, so the literal match is kept and a regression test now pins
the rejection.

Also covers the remaining functional gaps: `WeixinLoginClient.verify(...)` (blank code, code in
the status query, the verification challenge, untrusted redirect, provider failures) and the
outbound `notifyStart`/`notifyStop` endpoints. Module line coverage is 93%.
Follow-up to the second review round on agentscope-ai#3184.

**Correctness — the eviction rule dropped the cursor.** `prune` forgot any account whose lease
had expired, cursor included, so an account idle for longer than the lease TTL would have resumed
from `""` and replayed the provider backlog. The automatic sweep now only forgets accounts that
hold nothing at all (no cursor, no peer context, no inbox, no live lease) — the ids materialised
by validation paths that the previous commit stopped creating in the first place. Retiring real
state is now an explicit host action: `WeixinStateStore.removeAccount(accountId)` (default no-op,
implemented by the in-memory store), so a host can drop an account without the store guessing.
`keepsTheCursorWhenAnAccountGoesIdle` replaces the assertion that used to demand the opposite.

**Security — the login transport is query-string based.** The provider protocol puts `qrcode`
and `verify_code` in the GET status query, so the status URL is a credential; that is now stated
in the README with the operational consequence (no URI-level logging behind this client). The
module was verified not to echo anything itself: provider failures report only the status code,
and both JSON parse paths (login and outbound) now report line/column instead of Jackson's
default message, which quotes the offending input — a partially parsed login response contains
`bot_token`, and an inbox response contains message payloads and context tokens. Three tests
assert that an HTTP failure, an unparseable login body and a transport failure carry neither the
verify code, the QR id, nor body content.

Not changed: `fromProperties` still logs WARN on every construction — that is the intent, and
`create(...)` with an explicit store is the silent path.
…utcomes

Addresses the CHANGES_REQUESTED review on agentscope-ai#3184.

- **`longPollTimeoutMs` did nothing.** Every request used `requestTimeoutMs`, so a deadline shorter
  than the provider's long-poll window aborted every `getupdates` call. The client now derives the
  deadline per call: `longPollTimeoutMs` plus a short grace for `getupdates`, `requestTimeoutMs`
  for the control calls. `longPollSettingKeepsASlowGetUpdatesAlive` drives a 900 ms response with a
  300 ms request timeout and asserts it still succeeds.
- **A failed `notifystart` left the account polling.** The catch reported a transient failure and
  fell through into the poll loop with `providerStarted == false`, so with the lease still
  renewing the session was never started again and no `notifystop` was ever owed. The session now
  leaves the lease, so the outer loop backs off, reacquires and retries startup.
  `providerSessionStartIsRetriedAfterATransientFailure` fails `notifystart` twice and asserts the
  third attempt happens. This fix also exposed that `WeixinChannelLoopbackTest`'s fake provider had
  never implemented `notifystart` — it was returning 404 and the old code swallowed it — so the
  fixture now answers it.
- **A missing provider outcome counted as success.** `asInt(0)` accepted `{}` or any 2xx body
  without `ret`/`errcode` as a delivered message, after which the inbox claim completed and the
  reply was dropped. Both `updates(...)` and `assertSuccess(...)` now require an explicit provider
  result. `responsesWithoutAProviderOutcomeAreRejected` covers poll and send. Provider outcome and
  credential failures also keep their exception type instead of being wrapped in a generic
  `RuntimeException` by `sendWithContext`.
- **Byte-identical id-less messages collapsed into one.** The fallback inbox id was the payload
  digest alone, so two identical messages in one batch became one claim while the cursor advanced —
  a user typing the same thing twice lost one message. The fallback id now includes the position in
  the batch, which keeps a re-delivered batch deduplicated while separating genuine duplicates.
  `byteIdenticalMessagesWithoutIdsAreBothDispatched` covers it.

Test-design fix along the way: the runtime fixture now emulates a long poll (25 ms) instead of
answering instantly, which was spinning the consumer loop and starving the rest of the suite; the
lease-lifecycle suite dropped from 17.8 s to 7.0 s.
The Windows build failed in an untouched module: JdkHttpTransportTest
.testStreamErrorResponseWithEmptyBody timed out waiting for the stubbed server, so
agentscope-core failed first and every later module was skipped — this module never got
built. Re-running to get a clean signal.
WeixinChannelRuntimeTest.peerThrottleStopsDispatchAndSurfacesATransientFailure asserted the
transient-failure notification after calling channel.stop(), which races the poll loop: a stop
that lands between failMessage() and the loop's catch makes it skip the notification (correct
shutdown behaviour), leaving the list empty and the assertion red. It failed on the agentscope-ai#3186 CI run
on both runners.

Wait for the report before stopping instead. Same coverage, no race.
The channel sent each reply inline, inside the dispatch of the inbound message, and the client
required an explicit result code from every provider call. Both were wrong for iLink: an idle long
poll and an accepted send report success through their own fields (`get_updates_buf`, `msgs`,
`message_id`) with no `ret`, so every reply looked failed, the message returned to PENDING, and the
Agent ran again — one user message produced eight Agent executions and eight replies.

- Accept the provider's own way of reporting success: batch fields for `getupdates`, the message id
  for `sendmessage`. A body that carries neither the status fields nor those outcomes is still
  rejected, so a proxy error page cannot pass as a delivered reply.
- Poll and consume fail independently, so a provider outage cannot starve an accepted message and a
  message that cannot be dispatched cannot freeze the cursor.
- Bound dispatch retries and tombstone an abandoned message; report it through the new
  `onDispatchFailed` (a refused reply stays `onDeliveryFailed`). The Agent is never re-run for a
  reply the provider refused.
- Implement `deliverWithReceipt(...)` so a host that owns durable delivery can send one persisted
  reply and receive the provider's receipt. The inline path, used by standalone hosts and by the
  courtesy replies that answer an unbound peer, makes a single attempt.
- Report provider outcomes as structured codes; the provider's own error text never reaches a host
  log or reporter.
@DennisWLX
DennisWLX force-pushed the codex/feat-personal-weixin-channel branch from 44b5b0f to 82b31cb Compare September 20, 2026 11:19
Buktal added a commit to Buktal/agentscope-java that referenced this pull request Sep 20, 2026

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of the push since my last approval on this branch (80cfaf3782b31cb9, "settle reply delivery semantics in the channel extension"). Splitting "who owns retry" is the right call: the transport should not be doing backoff inside the dispatch budget, and it is what was letting a slow provider time the whole dispatch out and replay the Agent. Reporting the root cause instead of RetryExhaustedException, requiring a real provider receipt before calling a send delivered, and dropping errmsg from exception text are all improvements in the same direction, and the listener split (onDeliveryFailed vs onDispatchFailed) keeps a message-level failure from flipping the channel's runtime state.

Four findings inline, all on the new commit, and none of them is about the happy path:

  • [Warning] WeixinChannel.java:231 — on the inline path a failed send is swallowed and the claim still completes, so in a standalone deployment (no host queue behind it) the reply disappears with no second attempt.
  • [Warning] WeixinChannel.java:508 — a credential rejection is charged to MAX_DISPATCH_ATTEMPTS, so a 30-second token expiry permanently tombstones the three messages that were in flight instead of letting them recover after re-authorization.
  • [Info] WeixinChannel.java:77dispatchFailures has no eviction for messages that are never claimed here again, against the "bound in-memory state" goal of this PR.
  • [Info] WeixinChannel.java:617safeMessage decides what is safe to report by exception type, but IllegalStateException/IllegalArgumentException are also what the surrounding stack throws, and rootCause walks straight to them.

CLA status is not reported for this head (license/cla context absent) and CI (ubuntu + windows builds, license, module sync, codecov patch) is green, so I am leaving this as a comment rather than repeating an approval. Happy to re-review after the next push.


Automated review by github-manager-bot

Propagate inline send failures so a standalone consumer cannot complete an
inbox claim before the provider accepts its reply. Managed gateways still
return no reply after persisting delivery, and do not trigger an inline send.
Standalone retries retain the documented at-least-once processing semantics.

Release credential-rejected claims without charging their dispatch budget,
so reauthorization can recover a message even after two ordinary failures.
Scope retry counters to the lease session so a handover releases stale state.

Report text only for module-owned operational exceptions; generic state and
argument exceptions from a host or transport can contain secrets. Keep
structured provider codes while excluding arbitrary third-party messages.

Correct successful send fixtures to return actual provider receipts. Five
regression assertions failed before the fix, including credential recovery
after two failures and a lease handover; all now pass. The full Weixin suite
passes: 71 tests, 1 opt-in live test skipped. Spotless and module sync pass.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review after the new head (4229159, previously reviewed at 82b31cb). The incremental commit addresses the delivery-semantics feedback well: inline reply failures now propagate (doOnError instead of swallow) so the inbox claim stays retryable, the retry counters moved from a process-wide ConcurrentHashMap to a per-LeaseSession HashMap (fresh budget per tenure, no stale keys across takeover), and credential rejections no longer consume the poison-message budget. The WeixinOperationException refactor also tightens secret exposure — safeMessage now reports third-party IllegalStateException/IllegalArgumentException by type only, which is the right call since their messages can embed provider data (nice regression coverage via the 'token=TEST-SECRET' assertion).

Findings

  • Only two informational notes below (inline). No blockers in the incremental change itself.

Cross-repo / Merge note

This PR accumulated many external reviews after my previous COMMENT. I could not verify the resolution state of every human review thread from this run, and CI is still queued on the new head — treating this as a readiness COMMENT rather than an approval. Recommend a follow-up pass once the new-head CI finishes and existing threads are resolved.


Automated review by github-manager-bot

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of head 03d5f2aa (previous pass at 42291591). The increment is documentation-only — three added comment lines in WeixinChannel.java, no behavioral change — and the readiness condition I set last time (CI on the new head) is now satisfied: License, Module Sync, build (ubuntu-latest), build (windows-latest), codecov patch and license/cla are all green. I checked both new claims against the code rather than trusting them, and both are accurate. Holding at a readiness COMMENT: the only thing standing between this and my approval is acknowledgement or resolution of the two threads below, both of which I raised and both of which are informational rather than defects.

What I verified in the new comments

  • drainInbox() — "Called only by the consumer thread, which owns the session's dispatch retry counters." Confirmed. The only call site is WeixinChannel.java:359 inside consume(), which runs on the single-thread weixin-<accountId> executor built at lines 101-107, and LeaseSession.dispatchFailures (a plain HashMap, line 537) is mutated only at 502 / 511 / 513 — all inside drainInbox(). The other threads that can reach a session are the weixin-lease-renewal heartbeat (renew()loseLease()) and caller threads entering via dispatch(in)dispatch(in, active); both touch only session.valid (volatile) and session.cancelled, never the map. So the confinement the comment asserts is real, not aspirational — the javadoc is worth keeping as-is.
  • The credential-rejection note at 506-507 — confirmed. stateStore.failMessage()'s boolean is deliberately discarded at 508 and the error is rethrown into consume()'s catch (WeixinCredentialRejectedException) at 374, which sets running = false; the finally at 377-400 then cancels the renewal, calls loseLease() and releaseLease(). "Propagate either way so consume() stops this consumer and releases its lease" matches the control flow, including the fenced-claim case.

Open items from my earlier passes (unchanged by this commit)

  • WeixinChannel.java:336 — a notifystart failure exits consume() before the polling loop ever escalates, so poll()'s catch at 299-304 sleeps a flat backoff (1000 ms) while the polling path doubles it to maxBackoffMs. A provider down for an hour costs ~3600 notifystart attempts and ~3600 warn lines. Either grow the backoff on this path or lift the escalation into poll() so startup and polling share one policy.
  • WeixinOutboundClient.java:131timeoutFor() still derives the deadline from local longPollTimeoutMs + LONG_POLL_GRACE_MS and never consults the advertised longpolling_timeout_ms, which JsonNodeResponse parses (line 296) and drops. If the provider advertises a longer window than the local setting, the client still aborts the poll early — the failure mode the deadline commit set out to remove. Taking max(local, advertised) once known, or logging when advertised exceeds configured, closes it.

Verdict

No new findings on the delta itself, and nothing in this head argues against merge from my side. Resolve or explicitly dismiss the two threads above and this is good to go from the bot's perspective.


Automated review by github-manager-bot

Buktal added a commit to Buktal/agentscope-java that referenced this pull request Sep 21, 2026
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]: Add a Personal Weixin (iLink) channel extension

3 participants