feat(feishu): enrich observed contact labels - #8569
Conversation
Feishu Observed-Contact Label Enrichment E2EBaseline
Verification
Current result
|
Live Feishu E2E reportTested commit Verified
Not yet verified
No application credentials were written to the repository or included in this report. |
|
Gate re-run — four review-fix commits from the autofix loop have landed since the last triage pass, so this re-checks the gate against the current head.
Moving on to code review. 🔍 中文说明Gate 复查 —— 自上次 triage 以来 autofix 循环已合入四个 review-fix commit,本次按当前 head 重新过 gate。
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewRe-run at the current head — this pass covers the four review-fix commits landed since the last full review (bounded caches, observation-recency hydration, write de-duplication, silent token-refresh logging). My independent baseline for this problem was exactly the shape the PR uses: persist the ID-based observation immediately, then fire-and-forget a background lookup with per-instance singleflight caches, silent failures, and sanitized names. The PR matches it, and goes further in ways that each answer a real failure mode rather than speculate: restart hydration from the persisted registry so known labels don't regress to raw IDs after a daemon restart, a 500-entry cap on every runtime cache with re-hydration after eviction, a Checks I ran against the code, all clean:
No blockers, no convention violations. sequenceDiagram
participant P1 as Feishu WebSocket event
participant P2 as ChannelBase processInbound
participant P3 as ObservedContactStore
participant P4 as FeishuChannel enrichment
participant P5 as Feishu OpenAPI
P1->>P2: preflighted envelope
P2->>P3: persist ID-based observation
P2->>P4: onObservedContact hook, not awaited
P4->>P5: user and chat lookups, singleflight cached
P5-->>P4: names or silent failure
P4->>P3: enriched observation, same contact key
Testing — CI evidence via API (no PR code executed in this run)The macOS, Windows, and CLI-integration jobs are gated on
Sandboxed verification would settle the one claim still resting on the author's word: that live enrichment works against the real Feishu OpenAPI at the current head — the live E2E report in this thread ran against the pre-review-fix commit Not verified: live Feishu OpenAPI behavior (no credentials available here, and triage never executes PR code); Windows/Linux runtime behavior (author tested on macOS). The earlier maintainer change-request about the skipped integration suite is now moot by design — that job runs in the merge queue, not on PR push. 中文说明代码审查本次在当前 head 复审,覆盖上次完整审查之后合入的四个 review-fix commit(缓存上限、按观测时间回填、写入去重、静默 token 刷新日志)。 我独立构思的方案与 PR 的形状完全一致:先立即落盘 ID 观测,再以 fire-and-forget 方式后台查询,配合实例内 singleflight 缓存、失败静默和名称净化。PR 在此基础上更进一步,且每一点都对应真实故障模式而非臆测:daemon 重启后从持久化记录回填已知名称,避免标签退化为原始 ID;所有运行时缓存设 500 条上限,驱逐后重新回填; 逐项检查均无问题:singleflight 生命周期(请求发出前的 token 获取失败与 401 会删除查询条目、下一条消息可重试,401 同时失效缓存 token;请求已送达飞书的失败则缓存至重启,符合 issue 的"每实例至多一次");core-waiter 标记的竞态(标记在加入既有刷新之前设置、在 refreshToken 失败时读取,因此加入静默刷新的核心调用方仍能看到错误日志);渠道隔离(回填按 channelName 过滤,同一 daemon 上多个渠道实例互不泄漏标签);注入面(解析出的名称先经共享的 sanitizeSenderName 净化再缓存,chatId 经 encodeURIComponent 进入 URL 路径,持久化层在名称净化为 unknown 时本就会回退为 ID 标签)。 无阻断问题,无规范违规。上方时序图展示了关键路径:预检通过 → 立即落盘 ID 观测 → 钩子异步触发补全(不被等待)→ 查询成功后以相同联系人键补写一条带名称的观测。 测试 —— 通过 API 获取的 CI 证据(本次运行未执行任何 PR 代码)macOS、Windows 与 CLI 集成测试在 ci.yml 中限定 merge_group 事件触发 —— PR 推送时本就不运行,进入合并队列时才会运行,因此下表中的 skipped 是机制使然,与本 PR 无关。逐 PR 的 Ubuntu 门禁在受审 head 上为绿,包含新增的 24 条飞书适配器测试与 2 条 ChannelBase 测试。 沙盒验证可以了结目前仍依赖作者自述的唯一主张:当前 head 对真实飞书 OpenAPI 的在线补全 —— 本帖中的在线 E2E 报告是在 review-fix 之前的 commit c82d3f1 上跑的,此后四个修复 commit 仅有 mock 单测覆盖。 未验证:真实飞书 OpenAPI 行为(此处无凭据,且 triage 从不执行 PR 代码);Windows/Linux 运行时行为(作者仅在 macOS 上测试)。此前维护者关于集成测试被跳过的 change-request 现已因机制澄清而消解 —— 该任务在合并队列中运行,而非 PR 推送时。 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean across every stage at the current head; the only gap is that the live OpenAPI success path is covered at this head by mocked tests plus an author E2E from an earlier commit, which is exactly what the This is the third triage pass on this PR, and the progression is the opposite of review-bloat: each round removed a real gap. The original pass approved an unbounded-cache version; the review rounds since produced bounded caches, restart hydration that honors observation recency, redundant-write de-duplication, and silent-but-core-visible token logging — all small, each tested, none speculative. The result is now tighter than my independent proposal, not looser. Stepping back: the problem is genuinely observed (raw The one honest reservation, carried from Stage 2: the live success path against real Feishu OpenAPI is not independently verified at this head. That's a verification gap, not a code doubt — approving, with 中文说明置信度:4/5 —— 当前 head 在各阶段均无问题;唯一的缺口是真实 OpenAPI 在线成功路径在此 head 上只有 mock 测试与作者在更早 commit 上的 E2E 覆盖,这正是审查评论中 这是本 PR 的第三轮 triage,走向与"审查膨胀"相反:每一轮都在消除真实缺口。首轮通过的是缓存无上限的版本;此后的审查轮次补齐了缓存上限、按观测时间回填的重启水合、冗余写入去重、以及静默但对核心调用方可见的 token 日志 —— 每项都小、都有测试、都不臆测。最终实现比我独立构思的方案更收敛而非更发散。 整体看:问题是真实观测到的(探测出的联系人只有 唯一保留意见(承接 Stage 2):真实飞书 OpenAPI 的在线成功路径尚未在此 head 上独立验证。这是验证缺口而非代码疑虑 —— 予以批准;如有人在进入合并队列前想坐实该主张,上方已给出 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.8-max via Qwen Code /review (v0.21.5)
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Test Plan (not a blocker): npm test — exit 1.
中文说明
已审查——无阻断问题。 建议见行内评论。 Test Plan(非阻断):npm test — exit 1。
— qwen3.8-max via Qwen Code /review (v0.21.6)
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Autofix review round — PR #8569No base conflict was reported ( Feedback addressed this round
Findings re-verified as already fixed (commit 8ef1b1e)These threads were fixed in the previous round but remained open after the merge with main; each was re-verified against the current HEAD and is listed in
Other changes
Verification
中文说明Autofix 评审轮次 — PR #8569未报告与 base 的冲突( 本轮处理的反馈
复核确认已在 8ef1b1e 修复的发现这些线程在上一轮已修复,但在与 main 合并后仍处于打开状态;已逐一在当前 HEAD 上复核,并列入
其他变更
验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Disposition of this round's feedbackThe eleven new inline replies from @BenGuanRan each contain only a file reference ( Point-by-point evidence, all verified at the current head:
Check status note: the Verification
中文说明本轮反馈的处理@BenGuanRan 新提出的 11 条行内回复每条都只包含一个文件引用( 逐条证据如下,均在当前 head 上核验:
检查状态说明:本轮反馈中的 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
wenshao
left a comment
There was a problem hiding this comment.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
中文说明
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
— gpt-5.6-sol via Qwen Code /review (v0.21.7)
…nrichment token failures (QwenLM#8569) - hydrate runtime label caches from the newest observation per contact so stale group membership labels cannot overwrite more recent ones - cap the user/chat label, in-flight lookup, and write-dedup maps at 500 entries (matching the persisted registry) and evict oldest entries - route best-effort label lookups through a silent token refresh path so enrichment failures no longer write to stderr - add tests for silent token refresh, newest-label hydration, cache cap, and the persisted-observation reject path in hook ordering
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no code change neededEvery finding in this round's feedback is already resolved at the current PR head ( Inline findings — all verified resolved at HEAD
Failed check:
|
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no code change neededThis round's feedback window (comments newer than the last evaluation, 2026-08-07T04:13:09Z) contains sixteen inline comments and one still-red CI check. All sixteen comments are per-finding disposition records — "fixed in 93bd433 / pinned by test X / decline confirmed" notes on threads from the R1 batch and the earlier suggestion batches. None of them requests a new change. This round re-verified each claimed disposition against the code and tests at the current PR head ( Inline comments — all sixteen verified against HEAD
The fifteen verified-resolved findings are also listed in this round's Still-red check
|
|
Qwen Code review did not complete successfully. Qwen review aborted with an API error before posting comments. A transient error is retried automatically; if you are seeing this, retry with |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round: no code changes neededThis round's feedback contains no inline or review-comment findings — only two Triage of feedback points1. Evidence:
Action: re-run the failed Test job. No commit can influence the proxy/cache 2. Per the bot's own comment on this PR, the Qwen review aborted with an API 3. Issue-level comment from No base-conflict resolution was needed ( VerificationCommands actually run this round, on the unchanged tree (HEAD = the reviewed
Note: 中文说明Autofix 审查轮次:无需代码改动本轮反馈中没有任何行内评论或审查意见——只有两个失败的 check 和一条关于审查中止的机器人评论。经过基于证据的分类处理,这两个失败都不是本 PR 代码导致的,也都没有代码层面的修复手段。工作区保持不变( 反馈点分类处理1. 证据:
处理方式:重新运行失败的 Test job。任何提交都无法影响导致其失败的代理/缓存行为。 2. 按照机器人在本 PR 上的评论所述,Qwen 审查在发布评论之前因 API 错误中止;瞬时错误会自动重试,维护者也可以用 3. 无需解决与 base 分支的冲突( Verification(验证)本轮在未改动的代码树(HEAD = 被审查的合并提交)上实际运行的命令:
说明: Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x), review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x), review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no code change neededThis round's feedback contains no reviews, inline comments, or issue-level comments — only one item: the still-red Triage of the failed check
Evidence:
Disposition
VerificationCommands actually run this round on head
No code change was made this round; the branch head is unchanged. 中文说明Autofix 审查轮次 — 无需修改代码本轮反馈中没有任何审查意见、行内评论或 issue 级评论——只有一项内容:仍然为红的 失败检查的分类处理
证据:
处理结论
Verification(验证)本轮在 head
本轮未做任何代码改动;分支 head 保持不变。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Test Plan (not a blocker): npm test — exit 1.
中文说明
已审查——无阻断问题。 建议见行内评论。 Test Plan(非阻断):npm test — exit 1。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| if (this.tokenRefreshPromise) return this.tokenRefreshPromise; | ||
| this.tokenRefreshPromise = this.refreshToken(); | ||
| this.tokenRefreshPromise = this.refreshToken(options); |
There was a problem hiding this comment.
[Suggestion] R4-1: The silent flag is latched into the shared tokenRefreshPromise by whichever caller starts the refresh. An enrichment-initiated (silent) refresh therefore suppresses the token-error log lines for concurrent core delivery callers that join it — the comment this diff adds ("core delivery paths keep logging token errors") holds only when a core path initiates the refresh. — Failure scenario: with the tenant-token cache expired, the inbound enrichment hook calls getTenantAccessToken({ silent: true }) before the reply path and starts refreshToken({ silent: true }); while it is in flight (up to the 15 s timeout), sendMessageInternal / media download / card paths join the shared promise; if the token endpoint hangs or returns 5xx, both stderr write sites are skipped and delivery fails with only generic downstream errors ("Cannot send: no access token."), with the root-cause line suppressed for as long as steady inbound traffic keeps re-opening silent refreshes. Probe-verified at this commit: a joined core caller observes zero stderr writes after the shared failure, while a core-initiated refresh logs. Suggested fix: track non-silent joiners (e.g. set a tokenRefreshHasCoreWaiters flag in the early-return branch when !options?.silent, reset it in the finally alongside tokenRefreshPromise = undefined) and log in refreshToken's failure branches when a non-silent waiter exists.
中文说明
[Suggestion] R4-1:silent 标志由发起刷新的调用方锁定进共享的 tokenRefreshPromise。因此由补全发起的(静默)刷新会抑制并发加入该 promise 的核心投递调用方的 token 错误日志——本 diff 新增的注释("core delivery paths keep logging token errors")只有在核心路径发起刷新时才成立。—— 失败场景:tenant token 缓存过期时,入站补全 hook 在回复路径之前调用 getTenantAccessToken({ silent: true }) 并启动 refreshToken({ silent: true });在其在途期间(最长 15 秒超时),sendMessageInternal / 媒体下载 / 卡片路径加入共享 promise;若 token 接口挂起或返回 5xx,两处 stderr 写入均被跳过,投递仅以泛化的下游错误("Cannot send: no access token.")失败,且在持续的入站流量不断重新发起静默刷新期间,根因日志会一直被抑制。已在本提交上用探针验证:加入共享刷新的核心调用方在失败后观测到 0 条 stderr 输出,而核心路径发起的刷新会正常记录日志。建议修复:记录非静默的加入方(例如在提前返回分支中当 !options?.silent 时设置 tokenRefreshHasCoreWaiters 标志,并在 finally 中与 tokenRefreshPromise = undefined 一起重置),在 refreshToken 的失败分支中当存在非静默等待者时输出日志。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| const existing = options.lookups.get(options.id); | ||
| if (existing) return existing; |
There was a problem hiding this comment.
[Suggestion] R4-2: observedNameLookup dedups only on the lookups promise map, never the resolved names map. capObservedCache FIFO-evicts both maps at 500, and lookup entries are inserted before their name entries resolve, so after cache churn an already-resolved ID gets a fresh HTTP lookup even though its label is still cached. — Failure scenario: a long-running daemon observes >500 unique sender/chat IDs; the oldest (successfully resolved) lookup entry is evicted; when that ID sends another message, a fresh basic_batch request is issued even though observedUserNames still holds the label and the envelope already uses it — redundant rate-limited API traffic, contrary to the plan's "at most one query per ID per instance". Probe-verified: after simulated churn the PR code issues 1 fetch for the warm-cached ID; consulting the names map first removes it.
| const existing = options.lookups.get(options.id); | |
| if (existing) return existing; | |
| const cached = options.names.get(options.id); | |
| if (cached) return Promise.resolve(cached); | |
| const existing = options.lookups.get(options.id); | |
| if (existing) return existing; |
中文说明
[Suggestion] R4-2:observedNameLookup 只对 lookups promise map 做去重,从不查询已解析的 names map。capObservedCache 对两个 map 都按 500 条 FIFO 淘汰,且 lookup 条目先于其 name 条目写入,因此缓存周转之后,即使标签仍在缓存中,已解析的 ID 也会触发一次新的 HTTP 查询。—— 失败场景:长期运行的 daemon 观测到超过 500 个不同的发送者/会话 ID;最早的(已成功解析的)lookup 条目被淘汰;当该 ID 再次发消息时,即使 observedUserNames 中仍存有标签且 envelope 已在使用它,也会发起一次新的 basic_batch 请求——多余的受限 API 流量,与计划的"每个实例每个 ID 至多查询一次"相悖。探针验证:模拟缓存周转后,PR 代码对该仍在缓存中的 ID 发起了 1 次请求;先查询 names map 可将其消除。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| users: graph.users.filter((user) => user.channelName === this.name), | ||
| groups: graph.groups.filter((group) => group.channelName === this.name), | ||
| }; | ||
| } catch { | ||
| return undefined; |
There was a problem hiding this comment.
[Suggestion] R4-4: The channel-isolation filter and the swallowing catch in persistedObservedContacts() have no test that can detect their removal. Mutation-tested at this commit: deleting both .filter(...) conditions, or deleting the try/catch, leaves ChannelBase 562/562 and feishu 92/92 green. — Failure scenario: the hydrate fixture includes a foreign-channel group with member ou_foreign, but no message is ever sent from it — so removing the filters ships green, and labels persisted by another channel (the store is workspace-shared) hydrate into this channel's caches and mislabel prompts. Removing the catch also ships green: list() throws on a corrupted registry, the throw propagates through hydrateObservedNames() into FeishuChannel.onMessage, whose outer catch drops the first inbound message after restart instead of silently skipping hydration. Suggested fix: in the hydrate test, also deliver a message from the foreign-channel contact and assert labels stay raw IDs; add a case where list throws and assert the message is still processed.
中文说明
[Suggestion] R4-4:persistedObservedContacts() 中的 channel 隔离过滤与吞错 catch 没有任何测试能检测到它们被删除。已在本提交上做变异测试:删除两个 .filter(...) 条件,或删除 try/catch,ChannelBase 562/562 与 feishu 92/92 均保持绿色。—— 失败场景:hydration 测试 fixture 中包含一个外 channel 的群(成员 ou_foreign),但从未有该成员的消息——因此删除过滤后测试仍绿,而另一个 channel 持久化的标签(store 按 workspace 共享)会 hydrate 进本 channel 的缓存并导致提示词标注错误。删除 catch 同样能绿着上线:list() 在注册表损坏时会抛错,该异常会经 hydrateObservedNames() 传播到 FeishuChannel.onMessage,其外层 catch 会丢弃重启后的首条入站消息,而不是静默跳过 hydration。建议修复:在 hydration 测试中让外 channel 联系人也发一条消息并断言其标签保持原始 ID;再新增一个 list 抛错的用例,断言消息仍被正常处理。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| if (!silent) { | ||
| process.stderr.write( | ||
| `[Feishu:${this.name}] getTenantAccessToken failed: HTTP ${resp.status}\n`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
[Suggestion] R4-5: This silent-mode HTTP-error branch is exercised by the retry tests (retries a label lookup when token acquisition fails before the request, adapter.test.ts:563, and its chat twin) but never asserted silent — the tests install stderrSpy and only restore it in finally. The only silence assertion (keeps enrichment silent when tenant token acquisition fails, adapter.test.ts:1090) covers the catch branch, not this one. — Failure scenario: deleting the if (!silent) wrapper here leaves the suite 92/92 green, and a silent background lookup hitting a 503 then writes [Feishu:...] getTenantAccessToken failed: HTTP 503 to stderr (probe-verified: the mutant emits the line, PR code emits nothing) — violating the design's "lookup failures produce no log output" with the suite green. Suggested fix: add expect(stderrSpy).not.toHaveBeenCalled() before the finally in both retry tests — the spies are already installed and unused.
中文说明
[Suggestion] R4-5:这个静默模式的 HTTP 错误分支被重试测试(retries a label lookup when token acquisition fails before the request,adapter.test.ts:563,及其 chat 对应测试)执行到,但从未断言其静默——测试安装了 stderrSpy 却只在 finally 中恢复。唯一的静默断言(keeps enrichment silent when tenant token acquisition fails,adapter.test.ts:1090)覆盖的是 catch 分支,而非此分支。—— 失败场景:删除此处的 if (!silent) 包装后测试套件仍为 92/92 绿,而一次命中 503 的静默后台查询会向 stderr 写入 [Feishu:...] getTenantAccessToken failed: HTTP 503(探针验证:变异体输出了该行,PR 代码无任何输出)——在套件全绿的情况下违反设计文档的"查询失败不产生日志输出"。建议修复:在两个重试测试的 finally 之前加上 expect(stderrSpy).not.toHaveBeenCalled()——spy 已经安装且未被使用。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| const label = sanitizeSenderName(name); | ||
| if (label === 'unknown') return undefined; |
There was a problem hiding this comment.
[Suggestion] R4-6: The 'unknown'-label guard has no test — no enrichment test supplies a name that sanitizes to 'unknown'; deleting the guard leaves the suite 92/92 green (mutation-tested at this commit). — Failure scenario: with the guard removed, a Feishu user whose display name consists solely of strippable characters (e.g. only zero-width characters) sanitizes to 'unknown', gets cached in observedUserNames and reused on every later envelope — prompt attribution renders [unknown] instead of the ID, contradicting the design's "keep ID labels when lookup is unavailable". Probe-verified: the mutant caches 'unknown'; PR code resolves undefined. Suggested fix: add a lookup test where the API returns a name of only zero-width characters and assert the lookup resolves undefined and nothing is cached.
中文说明
[Suggestion] R4-6:'unknown' 标签守卫没有测试——没有任何补全测试提供会净化为 'unknown' 的名称;删除该守卫后套件仍为 92/92 绿(已在本提交上做变异测试)。—— 失败场景:删除守卫后,若某个飞书用户的显示名仅由可剥离字符(例如全是零宽字符)组成,净化结果为 'unknown',会被缓存进 observedUserNames 并在之后每条 envelope 中复用——提示词署名会渲染为 [unknown] 而非 ID,与设计文档的"查询不可用时保留 ID 标签"相悖。探针验证:变异体缓存了 'unknown';PR 代码解析为 undefined。建议修复:新增一个查询测试,令 API 返回仅含零宽字符的名称,断言查询解析为 undefined 且不写入缓存。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| this.hydrateObservedNames(); | ||
| const senderName = this.observedUserNames.get(senderId) || senderId; | ||
| const chatName = isGroup ? this.observedChatNames.get(chatId) : undefined; |
There was a problem hiding this comment.
[Suggestion] R4-8: After in-lifetime cache eviction, the next message from a previously enriched contact rebuilds the envelope with the raw ID fallback, and the initial recordObservedContact write then overwrites the registry's persisted name label with the ID. If the guaranteed re-lookup fails, the persisted name is destroyed and hydration cannot restore it (label === id entries are skipped) — hydration protects restarts only, not in-lifetime eviction. — Failure scenario: a long-running daemon resolves names for >500 distinct sender IDs; FIFO eviction drops an enriched contact's cache entries (lookup entries evict first, so a re-lookup fires); the contact's next message writes (user=ou_X, label='ou_X') over (user=ou_X, label='Alice'); if the re-lookup then fails — transient outage, 429 storm, or the optional scope revoked mid-lifetime — the failure is retained until restart, and the next restart's hydration skips the entry, so the persisted name is gone. Probe-verified at this commit (including a flip check: re-hydrating on actual eviction restores the label). Pre-PR this downgrade could not occur because Feishu labels were always IDs. Suggested fix: consult the persisted registry before falling back to the raw ID on a cold-cache miss (or reset hydratedObservedNames when capObservedCache actually evicts, so the next message re-hydrates from the still-intact registry).
中文说明
[Suggestion] R4-8:运行期缓存淘汰之后,此前已补全的联系人的下一条消息会以原始 ID 回退重建 envelope,而首次 recordObservedContact 写入会用 ID 覆盖注册表中已持久化的名称标签。若随后必然触发的重新查询失败,已持久化的名称即被销毁且 hydration 无法恢复(label === id 的条目会被跳过)——hydration 只保护重启场景,不覆盖运行期淘汰。—— 失败场景:长期运行的 daemon 为超过 500 个不同发送者解析了名称;FIFO 淘汰掉了某个已补全联系人的缓存条目(lookup 条目先被淘汰,因此必然触发重新查询);该联系人的下一条消息把 (user=ou_X, label='Alice') 覆盖为 (user=ou_X, label='ou_X');若重新查询随后失败——瞬时故障、429 风暴、或可选权限在运行中被回收——该失败会被保留到重启,而下次重启的 hydration 会跳过该条目,已持久化的名称就此丢失。已在本提交上用探针验证(含翻转验证:在实际淘汰时重新 hydration 可恢复标签)。PR 之前不会发生这种降级,因为彼时飞书标签恒为 ID。建议修复:冷缓存未命中时先查询持久化注册表再回退到原始 ID(或在 capObservedCache 实际发生淘汰时重置 hydratedObservedNames,让下一条消息从仍然完好的注册表重新 hydration)。
— qwen3.8-max via Qwen Code /review (v0.21.7)
) * Track core (non-silent) waiters on the shared tenant-token refresh so a silent-initiated refresh still logs token errors for joined delivery callers. * Short-circuit label lookups on the resolved names cache so evicted lookup entries do not trigger redundant API requests. * Re-hydrate label caches from the persisted registry after an in-lifetime cache eviction so the next initial write cannot clobber a persisted label with the raw ID. * Add mutation-proof regression tests for the channel-isolation filter, the list-failure swallow, the silent HTTP-error branch, and the 'unknown' label guard.
|
🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下: Autofix review-round summary — PR #8569 (round 4 feedback)All six inline findings from the automated reviewer were verified against the Findings and dispositions
All seven mutations (each fix removed, one at a time) were verified to fail Changed files
Conflict notesNone — Verification
No settings source changed (no schema regeneration needed). The touched 中文说明Autofix 审查轮次总结 — PR #8569(第 4 轮反馈)自动审查者的六条行内发现均已在被审查的确切提交上逐条核实,并已在代码中处理(三处生产代码修复,以及经变异测试证明能检测对应回归的回归测试)。没有拒绝或升级任何发现;无需解决冲突( 发现与处理
以上七种变异(每次移除一处修复)均已验证会使对应测试失败,恢复后的代码树通过全部测试。 变更文件
冲突说明无 — 验证
未变更任何 settings 源文件(无需重新生成 schema)。本次触及的行为由包内单元测试覆盖,因此无需运行 bundle/集成测试。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Test Plan (not a blocker): npm test — exit 1.
中文说明
已审查——无阻断问题。 建议见行内评论。 Test Plan(非阻断):npm test — exit 1。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| this.capObservedCache(this.observedUserNames); | ||
| this.capObservedCache(this.observedChatNames); | ||
| } |
There was a problem hiding this comment.
[Suggestion] R5-3: Sibling-guard asymmetry: hydrateObservedNames() discards the return value of these capObservedCache calls, while the sibling eviction site in observedNameLookup resets hydratedObservedNames on eviction precisely because evicting a resolved label risks the next initial write clobbering the persisted name with the raw ID. — Failure scenario: a daemon that has resolved >500 distinct labels evicts an entry in observedNameLookup and resets the flag; the next message's hydration re-inserts registry labels into the now-full cache, the cap evicts another label, but the return value is discarded and the flag stays true, so no re-hydration is scheduled; the evicted contact's next envelope is built with the raw ID and recordObservedContact overwrites the persisted name label; if its lookup entry was also evicted and the fresh lookup fails (e.g. 429, retained until restart by design), the name stays reverted until daemon restart. Probe-reproduced end-to-end at this commit. Suggested fix: mirror the sibling guard here (reset hydratedObservedNames when either cap evicts), ideally together with the insertion-order fix in the R5-10 thread — under sustained churn a bare flag reset re-runs a full synchronous registry read on the next message.
中文说明
R5-3: 兄弟守卫不对称:hydrateObservedNames() 丢弃了这些 capObservedCache 调用的返回值,而 observedNameLookup 中的另一个淘汰点在淘汰时会重置 hydratedObservedNames——正因为淘汰已解析标签后,下一次初始写入可能会用原始 ID 覆盖已持久化的名称。失败场景:当 daemon 解析过 500+ 个不同标签后,observedNameLookup 淘汰一个条目并重置标志;下一条消息的 hydration 把注册表标签重新插入已满的缓存,上限逻辑又淘汰另一个标签,但返回值被丢弃、标志仍为 true,于是不会安排再次 hydration;被淘汰联系人的下一条 envelope 以原始 ID 构造,recordObservedContact 覆盖已持久化的名称标签;若该联系人的查询条目也已被淘汰且新查询失败(如 429,按设计保留到重启),名称将一直保持回退状态直到 daemon 重启。已在本提交上用探针端到端复现。建议修复:在此处镜像兄弟守卫(任一 cap 发生淘汰时重置 hydratedObservedNames),最好与 R5-10 线程中的插入顺序修复一并处理——在持续搅动下,单纯重置标志会让下一条消息重新执行一次完整的同步注册表读取。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| if (!response.ok) { | ||
| if (response.status === 401) { | ||
| this.tokenCache = undefined; | ||
| options.lookups.delete(options.id); | ||
| } | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
[Suggestion] R5-4: The 401-vs-non-401 branch distinction in this block is pinned by no test: forcing if (response.status === 401) to always-true survives all 97 adapter tests (mutant measured with a validated harness), and moving options.lookups.delete(options.id) outside the 401 check also survives. — Failure scenario: a regression widening the condition would clear tokenCache on every failed lookup response — a rate-limited (429) or flapping (500) endpoint would invalidate the cached tenant token on every failed background lookup, forcing a full tenant_access_token refresh for each subsequent silent lookup (auth-endpoint request amplification in the exact hot path this PR adds); symmetrically, making non-401 failures retryable would re-request a persistently failing endpoint on every inbound message from each affected contact. Suggested fix: add a case beside the existing 401 tests — lookup returns 429/500, then assert the next lookup reuses the cached token (a fetch mock counting /auth/v3/tenant_access_token/internal calls expects exactly one) and that no second lookup is issued for the same ID.
中文说明
R5-4: 该代码块中 401 与非 401 的分支区分没有任何测试固定:把 if (response.status === 401) 变异为恒真后,全部 97 个适配器测试仍然通过(变异体已经过校验的测试框架实测);把 options.lookups.delete(options.id) 移到 401 判断之外同样能存活。失败场景:若回归放宽该条件,任何失败的查询响应都会清除 tokenCache——被限流(429)或不稳定(500)的接口会导致每次后台查询失败都使缓存的 tenant token 失效,后续每次静默查询都要完整重新获取 tenant_access_token(恰在本 PR 新增的热路径上造成认证接口请求放大);对称地,若使非 401 失败可重试,则每个受影响联系人的每条入站消息都会反复请求一个持续失败的接口。建议修复:在现有 401 测试旁新增用例——查询返回 429/500,然后断言下一次查询复用缓存 token(统计 /auth/v3/tenant_access_token/internal 调用次数的 fetch mock 应恰好为 1 次),且同一 ID 不会发起第二次查询。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| if (label === id) return; | ||
| const current = best.get(id); |
There was a problem hiding this comment.
[Suggestion] R5-5: The if (label === id) return; guard is load-bearing but unpinned: no hydration fixture feeds an ID-label entry, which is the common real registry state after any run where enrichment failed. Deleting the guard leaves all 97 tests passing (mutation measured; flip probe verified at this commit). — Failure scenario: with the guard gone, after a daemon restart hydration seeds observedUserNames/observedChatNames with raw IDs, and observedNameLookup's names.get short-circuit returns the ID as a "resolved" name — so no real lookup is ever issued for precisely the never-enriched contacts until cache eviction or another restart, silently disabling enrichment for them. Suggested fix: extend a hydration fixture with an ID-label entry (e.g. { id: 'ou_pending', label: 'ou_pending', lastObservedAt: ... }) and assert a lookup IS issued for it (user_ids: ['ou_pending']) and that the raw ID is not cached as a name.
中文说明
R5-5: if (label === id) return; 守卫起实际作用但没有测试固定:所有 hydration 测试夹具都没有提供「标签即 ID」的条目,而这正是任何一次补全失败后注册表的常见真实状态。删除该守卫后全部 97 个测试仍通过(已实测变异体,并在本提交上用翻转探针验证)。失败场景:守卫被删除后,daemon 重启时 hydration 会把原始 ID 播进 observedUserNames/observedChatNames,而 observedNameLookup 的 names.get 短路会把这个 ID 当作「已解析」名称返回——于是恰好是那些从未补全成功的联系人,在缓存淘汰或再次重启之前永远不会发起真正的查询,补全被静默禁用。建议修复:在 hydration 夹具中增加一条「标签即 ID」的条目(如 { id: 'ou_pending', label: 'ou_pending', lastObservedAt: ... }),断言会为其发起查询(user_ids: ['ou_pending']),且原始 ID 不会被缓存为名称。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| if (!senderName && !chatName) return; | ||
| const key = this.observedContactKey(envelope); |
There was a problem hiding this comment.
[Suggestion] R5-6: Chat-only enrichment (user lookup fails, group name resolves) is untested — only the user-only mirror case exists, while this PR's own docs anticipate the harm case ("cross-application IDs and external users may remain unresolved"). Mutating the guard to if (!senderName) return; passes the entire suite (flip-verified by probe at this commit). — Failure scenario: if that regression ships, group labels stop being persisted whenever the sender lookup fails — the common real case for external/cross-app senders in a readable group — and no test catches it. Suggested fix: add the mirror test — user lookup returns a non-zero API code while the chat lookup resolves 'Project Group'; assert the enriched observation keeps the ID user label and carries the resolved group label.
中文说明
R5-6: 仅群名补全(用户查询失败、群名解析成功)没有测试——目前只有「仅用户解析成功」的镜像用例,而本 PR 自己的文档明确预期了这种受损场景(「跨应用 ID 与外部用户可能无法解析」)。把守卫变异为 if (!senderName) return; 后整个测试套件仍全部通过(已在本提交上用翻转探针验证)。失败场景:若该回归上线,只要发送者查询失败,群标签就不再被持久化——这正是可读群组中外部/跨应用发送者的常见真实场景——而且没有任何测试能发现。建议修复:补充镜像测试——用户查询返回非 0 API code,而群查询解析出 'Project Group';断言补全观测保留 ID 用户标签并携带已解析的群标签。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| } finally { | ||
| this.tokenRefreshPromise = undefined; | ||
| this.tokenRefreshHasCoreWaiters = false; | ||
| } |
There was a problem hiding this comment.
[Suggestion] R5-7: The tokenRefreshHasCoreWaiters = false reset in this finally block is the only thing preventing a permanent stderr-noise regression, and it is unpinned: the existing core-waiter test runs exactly one refresh cycle and the silence test starts from a fresh flag — deleting the reset passes both. — Failure scenario: after a core-waited failed refresh (logged once), the flag stays true forever, so every later enrichment-only (silent) refresh failure writes getTenantAccessToken failed/error noise to stderr — re-introducing exactly the behavior an earlier round's Critical fixed. Harm-probed at this commit (stderr written twice under the mutant). Suggested fix: in the existing core-waiter test, after the first logged failure clear tokenCache again and trigger a second refresh via getTenantAccessToken({ silent: true }) only, asserting the stderr spy was still called exactly once.
中文说明
R5-7: 该 finally 块中的 tokenRefreshHasCoreWaiters = false 重置是防止 stderr 噪声永久性回归的唯一屏障,但没有测试固定:现有的核心等待方测试只运行一个刷新周期,静默测试又从全新标志开始——删除该重置后两个测试都通过。失败场景:在一次有核心等待方的失败刷新(输出一次日志)之后,标志永远保持为 true,此后每次仅补全(静默)的刷新失败都会向 stderr 输出 getTenantAccessToken failed/error 噪声——恰好复现了前几轮评审中 Critical 所修复的行为。已在本提交上做危害探针(变异体下 stderr 被写入两次)。建议修复:在现有核心等待方测试中,第一次失败日志之后再次清除 tokenCache,仅通过 getTenantAccessToken({ silent: true }) 触发第二次刷新,断言 stderr spy 仍只被调用一次。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| } | ||
| }); | ||
|
|
||
| it('retries a 401 lookup for the same ID once the token refreshes', async () => { |
There was a problem hiding this comment.
[Suggestion] R5-8: The 24-line token-refresh/401 fetch-routing mock in this test is byte-identical to the one in 'refreshes the token after a label lookup returns 401' (lines 605-628 vs 684-707, diff-verified), and a near-identical success-routing mock repeats in at least three more tests (~14 URL-routing mocks total in this file). — Failure scenario: if the token endpoint path, the token response shape (tenant_access_token/expire), or the batch response shape changes, every copy must be located and edited separately; a missed copy keeps stubbing the old contract, so that test either fails for the wrong reason or silently passes against an outdated stub. This PR already extracts repeated setup into jsonResponse/feishuGroupMessage/feishuDmMessage helpers for exactly this reason; the fetch routers were left behind that pattern. Suggested fix: extract one helper next to jsonResponse (e.g. mockLabelFetchRoutes({ staleToken401, userName, chatName })) that installs the URL-routing implementation, and have the enrichment tests call it with per-test overrides.
中文说明
R5-8: 本测试中 24 行的 token 刷新/401 fetch 路由 mock 与 'refreshes the token after a label lookup returns 401' 中的一字不差(605-628 行对 684-707 行,已用 diff 校验),且近似相同的成功路由 mock 在至少另外三个测试中重复出现(本文件共约 14 个 URL 路由 mock)。失败场景:若 token 接口路径、token 响应结构(tenant_access_token/expire)或批量查询响应结构发生变化,每一处副本都必须逐一找到并分别修改;漏改的副本会继续按旧契约打桩,导致该测试要么因错误原因失败,要么对着过期桩静默通过。本 PR 已经为此把重复的测试准备抽成了 jsonResponse/feishuGroupMessage/feishuDmMessage 辅助函数;fetch 路由器被落在了这一模式之外。建议修复:在 jsonResponse 旁抽取一个辅助函数(如 mockLabelFetchRoutes({ staleToken401, userName, chatName }))安装 URL 路由实现,让补全测试以各自的覆盖参数调用它。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| for (const [id, entry] of newestUser) { | ||
| this.observedUserNames.set(id, entry.label); | ||
| } |
There was a problem hiding this comment.
[Suggestion] R5-10: Hydrated labels are inserted newest-first (list() sorts by lastObservedAt descending and Map preserves insertion order), so once the cache exceeds the cap, FIFO eviction removes the NEWEST/hottest hydrated labels first — the inverse of what the eviction mitigation relies on. Probe-verified deterministically at this commit. — Failure scenario: with a near-full registry plus a few newly resolved contacts, every new successful lookup's capObservedCache deletes cache.keys().next() — the newest hydrated label — while older ones survive; the hottest contacts fall out of the cache first, and each eviction resets hydratedObservedNames, forcing a full synchronous registry re-read + parse on the next onMessage. Harm is modest (small file, bounded churn), but the ordering is mechanically inverted. Suggested fix: insert hydrated entries oldest-first so FIFO eviction removes the stalest labels — e.g. iterate newestUser/newestChat in reverse before set (both loops).
中文说明
R5-10: hydration 以「最新在前」的顺序插入标签(list() 按 lastObservedAt 降序排序,Map 保持插入顺序),因此缓存一旦超过上限,FIFO 淘汰会先移除最新/最热的 hydration 标签——与淘汰缓解机制所依赖的顺序恰好相反。已在本提交上用探针确定性验证。失败场景:注册表接近占满且新解析了若干联系人时,每次新的成功查询触发的 capObservedCache 都会删除 cache.keys().next()——即最新的 hydration 标签——而更旧的条目反而留存;最热的联系人最先掉出缓存,且每次淘汰都重置 hydratedObservedNames,迫使下一条 onMessage 重新同步读取并解析整个注册表。危害不大(文件小、搅动有界),但顺序在机制上是反的。建议修复:以「最旧在前」插入 hydration 条目,使 FIFO 淘汰移除最陈旧的标签——例如在 set 之前反向遍历 newestUser/newestChat(两个循环都改)。
— qwen3.8-max via Qwen Code /review (v0.21.7)
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 115 passed · 0 failed · 115 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:115 通过 · 0 失败 · 115 总计 Verification reportVerdict: merge-ready — 115/115 scripted assertions passed (0 unexpected failures). 中文摘要结论:merge-ready。核心主张(飞书观测联系人标签异步富化:先写 ID 观测,后台解析用户/群名后补写富化观测)通过 A/B 实证:head 侧 6 个场景全部出现预期的富化/缓存/重试行为,base 侧零 OpenAPI 请求、标签恒为 ID,差异完全归因于本 PR(表 1,证据图 A/B 结论:12 个单元(head/base × 6 场景)共 96 条脚本断言全部通过:富化写入顺序正确、二次消息不再发起查询(wire 计数冻结)、权限缺失保留 ID 标签且不阻塞投递、token 获取失败可重试、401 后失效重取、重启后从持久化注册表水合(零查询即出名称)、跨频道标签隔离、DM 场景只查用户不查群。边界探针 11 条全过(union_id/user_id 类型映射、恶意名称消毒、'unknown' 折叠拒绝、64 码点截断无孤立代理项、部分失败时半富化,图 测试有效性:5 个定点变异全部被测试套件击杀(9/7/3/4/1 条测试变红),无幸存者(图 Findings:仅 1 条 nit——静默发起的 token 刷新被核心调用者中途加入时,刷新级错误日志会被抑制(窗口极窄,且发送路径仍有 未覆盖:逐提交归因(浅克隆仅 3 个提交可达)、真实飞书联调、Windows/Linux 实机、全仓 lint/typecheck(CI 覆盖)、作者所述 macOS/Node 25 下 15 条未改动 CLI 测试失败(本容器 Linux/Node 22 无法归因)、docs 文案审阅。 Central claim and A/B proofCentral claim: Feishu observed-contact entries are enriched with sender display names and group names — an ID-based observation is persisted immediately, lookups run in the background, an enriched observation is written when either lookup succeeds, completed lookups are cached for the channel lifetime, and token-acquisition failures before a lookup remain retryable — all without blocking message delivery. Environment per cell:
12/12 cells pass; 96 scripted assertions. The base cells encode the expected absence (zero enrichment, zero wire traffic) and pass as predicted — the flip from "IDs only, silent" on base to "enriched, cached, retrying" on head is wholly attributable to this PR. Witness: Secondary claims verified: delivery is never blocked by lookups ( Boundary probes on the changed surface (11 assertions, all pass; CorrectionsNone — no prior review round exists for this PR (no FindingsNit: refresh-level token error can be silenced when a core caller joins a silent-initiated refresh
Not covered
Targeted gates (head)
Suite liveness proven by mutation rather than assumed — see below. Vacuity / mutation matrix (5/5 killed, no survivors)Each mutant is a single-point source edit applied via
Unmutated controls green on both sides (97/97, 586/586). No mutant regressed a kill; no survivors to adjudicate. Witness: MethodologyRan in the CI verify container ( Evidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅









What this PR does
This change enriches Feishu observed-contact entries with the sender's display name and the group's name. It records the existing ID-based observation first, resolves both names in the background, and writes an enriched observation when either lookup succeeds. Completed OpenAPI lookups are cached for the lifetime of the channel instance so repeated messages do not repeatedly call Feishu OpenAPI, while a token acquisition failure before the lookup can retry.
Why it's needed
Feishu message callbacks identify senders and chats by ID, so daemon contact discovery currently exposes opaque labels such as
ou_…andoc_…. Recognizable labels make discovered contacts usable in channel-management interfaces while preserving message delivery when permissions are absent or Feishu OpenAPI is unavailable.Reviewer Test Plan
How to verify
Configure a Feishu bot with
contact:user.basic_profile:readonlyandim:chat:readonly, start the daemon, and mention the bot in a group. Confirm the message reaches the Agent immediately, the initial observation remains ID-based, and a later observation updates the sender and group labels. Send a second message from the same sender in the same group and confirm no additional name requests are issued. Then remove either permission and repeat with a new contact; message processing should continue, the unresolved ID should remain as the label, and no lookup-specific error should be logged.Automated verification passed with 561 channel-base tests and 80 Feishu adapter tests.
npm run build,npm run typecheck, andnpm run lintalso completed successfully. A fullnpm testrun exposed 15 failures in two untouched CLI test files (14 AuthDialog interaction assertions and one memory-diagnostics symlink-cleanup assertion); both the tests and their production code are identical toorigin/main, and the failures reproduce when those files run alone on this macOS/Node.js 25 environment.Evidence (Before & After)
Before: the observed user and group labels equal their callback IDs (
ou_…andoc_…).After: the ID observation is still available immediately, followed asynchronously by an observation whose labels contain the Feishu user and group names. Automated tests hold the lookups unresolved while confirming that the Agent prompt has already started, delay a second reply until after enrichment and confirm its stale snapshot is healed, and verify token-acquisition retry plus 401 invalidation.
Tested on
Environment (optional)
Node.js 25.2.1, native macOS environment without a sandbox.
Risk & Scope
Linked Issues
Closes #8566
中文说明
本 PR 做了什么
此变更为飞书已观测联系人补全发送者显示名和群名。它先记录现有的 ID 观测结果,再在后台并行查询两个名称;任一查询成功后,就补写一条带名称的观测结果。已实际执行的 OpenAPI 查询会在 channel 实例生命周期内缓存,因此重复消息不会反复调用飞书 OpenAPI;若在查询前获取 token 失败,后续消息仍可重试。
为什么需要
飞书消息回调使用 ID 标识发送者和会话,因此 daemon 联系人探测目前只能展示
ou_…、oc_…之类的不透明标签。可识别的标签能让探测到的联系人直接用于 channel 管理界面,同时在权限未开通或飞书 OpenAPI 不可用时继续保障消息投递。Reviewer 测试计划
如何验证
为飞书机器人配置
contact:user.basic_profile:readonly和im:chat:readonly,启动 daemon,并在群里艾特机器人。确认消息立即进入 Agent,首次观测仍使用 ID,随后出现一条更新了发送者和群标签的观测结果。由同一发送者在同一群再次发送消息,确认不会新增名称查询。然后移除其中一个权限,换一个新联系人重复验证;消息处理应继续进行,未解析的 ID 应保留为标签,并且不应输出名称查询专属错误。自动验证已通过 561 条 channel-base 测试和 80 条飞书适配器测试。
npm run build、npm run typecheck和npm run lint也均已成功完成。全量npm test暴露了两个未改动 CLI 测试文件中的 15 条失败(14 条 AuthDialog 交互断言和 1 条内存诊断符号链接清理断言);这些测试及其生产代码与origin/main完全一致,并且在当前 macOS/Node.js 25 环境中单独运行相应文件也能复现。证据(变更前后)
变更前:已观测用户和群的标签等于回调中的 ID(
ou_…和oc_…)。变更后:ID 观测结果仍会立即可用,随后异步出现一条以飞书用户名和群名作为标签的观测结果。自动化测试会在查询未完成时确认 Agent prompt 已开始;还会把第二条回复延迟到补全写入之后,确认旧快照会被再次修复,并验证 token 获取失败重试与 401 失效处理。
测试平台
环境(可选)
Node.js 25.2.1,macOS 原生环境,未启用 sandbox。
风险与范围
关联 Issue
Closes #8566