Skip to content

[Channel RFC] Lark outbound + streaming wire-through on LarkChannelAdapter (post-#261) #294

@eanzhao

Description

@eanzhao

Parent RFC: #254
Follows: #261 (PR #288)

Background

PR #288 landed the Lark adapter under #261:

  • new LarkChannelAdapter : IChannelTransport, IChannelOutboundPort
  • webhook ingress runtime + durable inbox (LarkConversationInboxRuntime via IStreamProvider)
  • canonical-keyed ConversationGAgent (replaces sender-keyed ChannelUserGAgent for Lark)
  • full inbound cutover (Day One flow + group chat)

But the outbound reply path still goes through legacy LarkPlatformAdapter.SendReplyAsync via Nyx proxy. The new adapter's outbound surface (SendAsync / UpdateAsync / DeleteAsync / BeginStreamingReplyAsync) is implemented + conformance-tested, but not wired into the Day One host flow.

User-visible streaming replies today still work via PR #241's legacy mechanism. No regression. The gap is architectural: issue #261's acceptance item "Streaming reply 在新 StreamingHandle API 下跑通" was met at adapter-surface level only, not at Day One host-path level.

We're choosing Day One experience + RFC #254 trunk over forcing the wire-through into #261, so this issue owns the remaining cleanup.

Scope

Wire the host outbound path onto IChannelOutboundPort, resolve bot-token lifecycle, continuation-ize the actor turn, and close the Lark adapter parity gaps. Remove the last direct dependency on LarkPlatformAdapter from the Lark host path.

Deliverables

A. Bot credential resolution for LarkChannelAdapter

Current state: LarkConversationAdapterRegistry.AdapterSnapshot.From writes `access_token = string.Empty`; any SendAsync call immediately fails with `credential_resolution_failed`. That is why the cutover in PR #288 only uses the new adapter for inbound — outbound would instantly error out.

Pick one of two routes and write a short ADR under `docs/decisions/`:

  • A1. Route through Nyx proxy — adapter accepts a Nyx-proxied `HttpClient`; Lark auth headers injected by proxy. Preserves "platform token never touches our process". Cross-cuts with Telegram adapter migration ([Channel RFC] Telegram adapter migration (shim → full TelegramChannelAdapter) #262).
  • A2. Direct Lark auth — resolve `tenant_access_token` from `app_id` / `app_secret` via `/open-apis/auth/v3/tenant_access_token/internal`, cache in adapter with ~2h TTL + refresh-on-401 / refresh-on-expired-code. Matches the RFC's "adapter talks to platform SDK directly" line. Entails a token-cache contract that other direct-auth adapters can reuse.

Acceptance: LarkChannelAdapter.SendAsync succeeds end-to-end against a real (or faked) Lark API without falling back to the legacy NyxID path.

B. Reply generator streaming surface

IConversationReplyGenerator.GenerateReplyAsync currently returns `Task<string?>` after collecting all deltas (`NyxIdConversationReplyGenerator` buffers into `StringBuilder`).

Change to either:

  • expose `IAsyncEnumerable` alongside the terminal text, or
  • accept a `StreamingHandle` and drive `AppendAsync` / `CompleteAsync` directly.

Keep the non-streaming shape for callers that don't need streaming (test stubs, Telegram until #262).

C. Turn runner outbound cutover

LarkConversationTurnRunner.SendReplyAsync(..., registration, ct) currently resolves an `IPlatformAdapter` and calls legacy `SendReplyAsync(replyText, inbound, registration, nyxClient, ct)`.

Replace with: resolve `LarkChannelAdapter` via `LarkConversationAdapterRegistry` → `BeginStreamingReplyAsync(conversation, initial, ct)` → pump chunks from reply generator (B) → `CompleteAsync(final)`.

  • `RunContinueAsync` (proactive) switches to `adapter.ContinueConversationAsync(reference, content, auth, ct)` with `AuthContext.Bot(...)`. The current legacy path hard-rejects `OnBehalfOfUser`; new contract supports it so remove that guard.
  • Retire `LarkPlatformAdapter.SendReplyAsync` usage from the Lark host path. The class itself may stay until Telegram migration ([Channel RFC] Telegram adapter migration (shim → full TelegramChannelAdapter) #262) lands.

D. Actor turn continuation-ization

Depends on C.

ConversationGAgent.HandleInboundActivityAsync currently `await runner.RunInboundAsync(activity)` inside the actor turn. If C is wired naively, every streamed chunk (Lark API call per delta) runs inside the single-thread actor turn; same-conversation messages serialize behind the full LLM generation.

Refactor to match CLAUDE.md Actor 执行模型 — 跨 actor 等待 continuation 化:

  • Actor turn emits `ConversationTurnStartedEvent` with minimal bound context (activity id, conversation, bot, metadata).
  • Background work (LLM generation + streaming-handle pumping) runs outside the turn, publishes `ConversationTurnCompletedEvent` / `ConversationContinueFailedEvent` back into the actor.
  • Actor turn on continuation event applies dedup bookkeeping (already event-sourced via `PersistDomainEventAsync`).

Acceptance: `ConversationGAgent` turn completes in O(1) work; LLM-generation duration no longer extends the turn.

E. Inbound parity gaps

`LarkChannelAdapter.ExtractTextContent` drops `message_type` ≠ `text` (image / file / sticker / post / rich text) silently.

Either:

  • surface them as `ChatActivity` with appropriate `Content.Attachments` / `Content.Cards` / typed sub-messages, or
  • declare explicitly in `ChannelCapabilities` + document in the package README that non-text inbound is unsupported.

No silent drops.

F. Ephemeral disposition honesty

`LarkChannelAdapter.SendCoreAsync` silently rewrites `MessageDisposition.Ephemeral` → `Normal`. Caller's `EmitResult` reports `capability: Exact`.

Either:

  • return `EmitResult.Failed("ephemeral_unsupported", ...)`, or
  • surface `capability: Degraded` + error code so caller knows the message was broadcast, not private.

G. Region / domain configuration

`LarkChannelDefaults.DefaultBaseAddress = https://open.feishu.cn\` hardcoded; global Lark tenants on `open.larksuite.com` can't use this adapter.

Expose via options + per-binding override, plumbed through the named `HttpClient` factory.

Acceptance

  • ADR in `docs/decisions/` captures A1 vs A2 choice with rationale
  • `LarkChannelAdapter.SendAsync` / `UpdateAsync` / `BeginStreamingReplyAsync` operate end-to-end without the legacy Nyx `IPlatformAdapter.SendReplyAsync` dependency
  • Day One flow (daily-report + social-media) sends its reply through `IChannelOutboundPort.BeginStreamingReplyAsync` — verified by host-cutover integration test
  • LLM streaming deltas patch the rendered Lark card progressively without requiring the actor turn to block on each chunk
  • `ConversationGAgent` turn completes in O(1) work; LLM-generation duration no longer extends the turn
  • Non-text inbound either parses into typed `ChatActivity` or is explicitly documented as unsupported (no silent drops)
  • Ephemeral sends return non-success / degraded capability; caller observes the downgrade
  • Region override (`larksuite.com`) verified via integration test with overridden `HttpClient` base address
  • Token refresh exercised by a fault test (expired-code + refresh → retry succeeds within one turn)

Out of scope

Dependencies

References

  • RFC §5.6 StreamingHandle contract
  • RFC §9.6 credential_ref
  • RFC §10.1 Lark adapter
  • CLAUDE.md Actor 执行模型 — 跨 actor 等待 continuation 化
  • CLAUDE.md Command / Envelope / Dispatch — ACK 诚实

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions