You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/`:
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.
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.
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)
Parent RFC: #254
Follows: #261 (PR #288)
Background
PR #288 landed the Lark adapter under #261:
LarkChannelAdapter : IChannelTransport, IChannelOutboundPortLarkConversationInboxRuntimeviaIStreamProvider)ConversationGAgent(replaces sender-keyedChannelUserGAgentfor Lark)But the outbound reply path still goes through legacy
LarkPlatformAdapter.SendReplyAsyncvia 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 onLarkPlatformAdapterfrom the Lark host path.Deliverables
A. Bot credential resolution for
LarkChannelAdapterCurrent state:
LarkConversationAdapterRegistry.AdapterSnapshot.Fromwrites `access_token = string.Empty`; anySendAsynccall 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/`:
Acceptance:
LarkChannelAdapter.SendAsyncsucceeds end-to-end against a real (or faked) Lark API without falling back to the legacy NyxID path.B. Reply generator streaming surface
IConversationReplyGenerator.GenerateReplyAsynccurrently returns `Task<string?>` after collecting all deltas (`NyxIdConversationReplyGenerator` buffers into `StringBuilder`).Change to either:
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)`.
D. Actor turn continuation-ization
Depends on C.
ConversationGAgent.HandleInboundActivityAsynccurrently `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 化:
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:
No silent drops.
F. Ephemeral disposition honesty
`LarkChannelAdapter.SendCoreAsync` silently rewrites `MessageDisposition.Ephemeral` → `Normal`. Caller's `EmitResult` reports `capability: Exact`.
Either:
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
Out of scope
Dependencies
References