Skip to content

feat(social): answer a friend's Bot in the group that calls it (#825) - #836

Merged
Birfy merged 1 commit into
mainfrom
feat/825-group-mention-answers
Oct 23, 2026
Merged

Birfy merged 1 commit into
mainfrom
feat/825-group-mention-answers

Conversation

@Birfy

@Birfy Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #825

Written-by: Dev3
Tip SHA: 6071242

Reproduce

Before this change, a line that named a friend's Bot in a group never came back into that group:

  • fanoutRoomLine (core/src/host/room-membership.js) sent an op: 'line' envelope with no calls and no hop, so the receiving home could not know which of its Bots the line named.
  • applyInboundRoomShare appended that line as a bubble and started nothing: the projected room had no rt.startInboundTurn, so no turn ever ran for the named Bot.
  • A line in a projected room returned early from fanoutRoomLine (room.origin === 'inbound-share'), so even a turn started there had no way back to the group it mirrors.
  • hooks.js posted sent — its replies appear in its own chat, not in this group for every successful mention, which is what a user saw instead of an answer.

The new tests in core/test/remote-room.test.js fail on all four points; see Red without the source change.

What changed

  • core/src/shared/room-share.js: the envelope carries hop (0–99) and calls (up to 24 handles), validated on both write and read.
  • core/src/host/room-membership.js:
    • fanoutRoomLine puts calls: callsIn(room, line.text) and hop on every line it sends: a person's line is hop 0, a local Bot's line is one hop on.
    • A line in a projected room (origin: 'inbound-share') goes back to the inviter who shared it, as an op: 'line' envelope under their roomId, one hop on, carrying the calls it makes. An answer the other side's owner holds or refuses produces one notice line in the room (held for approval / rejected by its owner) instead of silence.
    • applyInboundRoomShare keeps only the calls that name this room's own Bot (localCalls, matched on the room's invitedHandle and local Bots, never by reading @ out of the text), opens one turn per call through rt.startInboundTurn, and skips it when the line's hop has reached room.maxHops (DEFAULT_MAX_HOPS = 3) or when the same remoteRoomId:seq:botId was already answered (last 200 keys in room.answered).
    • recordInboundSharedRoom stores maxHops.
  • core/src/host/groups.js: rt.startInboundTurn opens the participant session, runs one turn (participant.runTurn) with the remote line quoted and fenced as data, and appends the answer as an ordinary room line, which is what sends it back. appendRoom stamps each line through a stampRoomLine seam wired in install() (same pattern as fanoutRoomLine), so a top-level helper never reaches for rt.
  • core/src/host/hooks.js: mentionReceipt no longer posts the "sent" line; deny/hold receipts stay.
  • Docs: docs/subsystems/core-host.md (the shared-room bullet and the mention-receipt bullet), docs/subsystems/plugin-runtime.md (bullet 8), and changelog.d/825-group-mention-answers.added.md.
  • Regenerated patches/ds-bot/ from core/ (node scripts/ds-bot-patches.mjs generate).

What I ran

At the tip, in core/:

$ DSH_HARNESS= node --import ./test/harness-resolve.mjs --test --test-concurrency=2 test/*.test.js
# tests 1294 / pass 1294 / fail 0
$ node --import ./test/harness-resolve.mjs --test test/remote-room.test.js
# pass 44 / fail 0
$ node --import ./test/harness-resolve.mjs --test test/flybot.test.js
# pass 43 / fail 0
$ node scripts/lint.mjs
Found 43 warnings and 0 errors
$ node scripts/check-arch-docs.mjs
arch-docs: OK (16 changed file(s))
$ node --import ./test/harness-resolve.mjs --test test/check-arch-docs.test.js
# pass 5 / fail 0
$ (repo root) node scripts/ds-bot-patches.mjs check
ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ node .github/scripts/changelog.mjs check
changelog: 9 fragment(s) in changelog.d/ are well formed

The 1294/1294 run needs git on PATH and a refs/remotes/origin/main ref; without git the one check-arch-docs.test.js case fails with cannot diff against HEAD, which is the test bench, not the change (it passes 5/5 with git present).

Red without the source change

Four controls, each applied alone to the tip and reverted (44/44 green again after each revert):

Control Result
drop calls: callsIn(...) from the line envelope #825: a line carries the calls it makes… fails (deepEqual gets undefined); 43 pass / 1 fail
drop the rt.startInboundTurn call in applyInboundRoomShare #825: a called Bot in a projected room gets one turn per line… fails (0 !== 1); 43/1
make the hop check always true (hop >= 0) same test fails at the cap (3 !== 2); 43/1
drop the room.answered dedupe same test fails on redelivery (2 !== 1); 43/1

The hooks.js half is pinned by the updated core/test/flybot.test.js: with the success receipt back, the test would wait 2 s for a receipt that must no longer appear.

Not run

  • dev/bench.sh smoke (needs a bench checkout and a port; the change is in src/host/), and the plugin suites (plugins/* untouched).
  • A real two-home run: two FlyBots with published Bots in one shared room. The tests drive applyInboundRoomShare/fanoutRoomLine against the mock model, not two live homes.
  • No browser pass over the projected room's UI; the answer is an ordinary room line, which the existing rendering already covers.

Risk

  • A Bot in a shared room now runs a turn when a remote line names it. The hop cap (3 by default, per room) and the one-answer-per-(roomId, seq, botId) key bound the chain, but a room with maxHops raised answers further.
  • The turn starts from applyInboundRoomShare without awaiting it, so a slow model does not hold up the envelope; a failure is logged as a host warning instead of reaching the group.
  • The other side's owner approval still applies (hold/deny), and remote text stays inside fencedUntrusted.
  • room.answered and the envelope's calls list are bounded (200 keys, 24 handles); a line naming more than 24 Bots only calls the first 24.

A @mention of a friend's Bot in a group used to leave the group and land in
that Bot's own chat, so the group never heard its answer. The room line now
carries the handles it calls and the hop it is at; the friend's FlyBot opens
one turn for the Bot the line names, and the answer comes back as a line of
the same room. The hop cap (3 by default) and one answer per line keep two
Bots from calling each other forever.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 23, 2026
@Birfy Birfy self-assigned this Oct 23, 2026
@Birfy

Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 6071242
Written-by: Reviewer2

Ran:

  • https://codeload.github.com/ThinkFlowLab/FlyBot/tar.gz/6071242d… extracted to a clean tree; DSH_HARNESS=<installed harness>, then in core: node --import ./test/harness-resolve.mjs --test test/remote-room.test.js test/flybot.test.js → 87 tests, 87 pass, 0 fail (44 + 43).
  • Knife 1 — calls: callsIn(room, line.text) dropped from the room-wide line envelope (src/host/room-membership.js:1016): #825: a line carries the calls it makes and its hop… red, 43/44; restored → 44/44.
  • Knife 2 — the rt.startInboundTurn(...) call removed from applyInboundRoomShare: #825: a called Bot in a projected room gets one turn per line… red at 0 !== 1; restored → 44/44.
  • Knife 3 — the cap check turned into hop >= 0: same case red at at hop == maxHops no turn starts, 3 !== 2; restored → 44/44.
  • Knife 4 — the room.answered.includes(key) skip removed: same case red at the same line delivered twice opens one turn, 2 !== 1; restored → 44/44.
  • Knife 5 — the success receipt put back in mentionReceipt (src/host/hooks.js): mentions in a group (#80, #81)… red at expected: 0, actual: 1; restored → 43/43.
  • Wrote a throwaway jsdom-free probe on the same host() fixture (deleted afterwards, not part of this verdict's evidence) to test the one rule the new tests do not drive — see the first note.
  • Re-read right before writing: head 6071242d749219bbd4bd3617f91b642cd7608b84, base 82afc513…, mergeable: true, mergeable_state: unstable, labels agent-pr, zero comments and zero reviews, so this tip carried no conclusion. Ten code checks completed/success; enqueue-guard is red with do not enqueue #836: no Verifier report names the tip 6071242d7492, which is the missing verdict, not a failure.

What I checked:

  • The envelope half: encodeRoomShare accepts hop (clamped to 99) and calls (≤ 24, each ≤ 120 chars), and the sender resolves them where this repository already resolves mentions — callsIn runs mentionSpans over the remote participants' handle, display name and label, so a fenced or quoted @name stays literal (pinned by the new case: a ``` block and an inline `@Coder` produce no `calls`).
  • The receiving half never parses text: localCalls intersects the peer's calls with the handle this room was invited as (sharedFrom.invitedHandle) and the local Bots' handles/ids, and applyInboundRoomShare starts a turn only for those. A line naming somebody else's Bot opens nothing (pinned).
  • The two hard constraints behave: the chain stops at hop == maxHops (DEFAULT_MAX_HOPS 3, per-room maxHops), and a redelivered line is answered once — the key is the remote roomId:seq:botId, not the local copy's new seq, which is the mistake the comment says it avoids; room.answered keeps 200 keys.
  • Remote text stays data on both paths: an inbound line goes through rt.authoredRoomLine with remote: true, which runs fenceRemoteText (unless it already carries the fence), and startInboundTurn quotes that stored, fenced text into the prompt rather than the raw envelope. The outbound side keeps fenceUntrusted for projected-room lines as well.
  • The answer really goes back into the mirrored group: the projected-room branch sends an op: 'line' envelope to sharedFrom.inviterHandle under the inviter's roomId, one hop on, carrying the calls it makes there; a hold or a deny from the other owner becomes one notice line in the room instead of silence (pinned), and a plain sent posts nothing extra.
  • The receipt removal is the same change seen from the group side: mentionReceipt now returns undefined for a successful send and drops those lines, keeping not sent and held for your approval; the updated flybot.test.js asserts zero receipts for a plain mention and the deny/hold lines still arrive in order.
  • groups.js: appendRoom stamps through a stampRoomLine seam wired in install() (no top-level reach for rt), startInboundTurn opens the participant Session, runs one turn with the remote line quoted, and appends the answer as an ordinary room line — so the existing round/rendering machinery carries it, and no client code is touched (no client.js in the diff).

Notes (non-blocking, not merge-gate items):

  • src/host/room-membership.js:983 computes the projected room's hop as Number(line.hop) || (Number(room.hop) || 0) + 1 for every author, while the room-wide branch at :1015 and the docs (docs/subsystems/core-host.md: "a person's line is hop 0 and a Bot's answer is one hop on") make a person's line hop 0. On my probe a human line in a projected room came out at 1, 2 and 4 for room.hop 0, 1 and 3 — i.e. once the room had taken a couple of Bot answers, the owner's own line would be sent at or above the cap and the other home would answer nothing. It is latent, not live: postRoomMessage refuses a human in a projected room (You are not a member of that room, since its participants are only the invited Bot), and inbound lines are not re-fanned, so today only the local Bot writes there and the formula is right. Worth a follow-up before anyone gives the owner a voice in that room.
  • The projected branch's calls: callsIn(room, line.text) (:984) is not covered by any new case: my knife 1 applied there left the suite at 44/44, and only the room-wide line's calls (:1016) is pinned. The back-to-the-inviter path only asserts roomId, hop and the text.
  • A turn started from a peer's line is detached (void Promise.resolve(...)) with signal: new AbortController().signal that nothing ever aborts: a slow or looping model keeps that turn running until the plugin disposes, and the group sees nothing about it. #825 accepted "a failure is logged as a host warning", but there is no cancel path at all.
  • A friend's home chooses seq and hop per line, so the 200-key dedupe and the hop cap bound the chain, not the rate: a peer whose Bot sits in one of my rooms can start a turn in my home whenever it names my handle with a fresh seq. That is the feature working as specified (only accepted rooms, only the invited handle, the reply still passes my send policy), but it is a new way for another home to spend my model time and tools, and it is worth one line in the docs.
  • ds-bot-patches.mjs check needs git, which this machine does not have (spawnSync git ENOENT); the package and ubuntu-latest checks on this head run the real script against the regenerated patches and are green.

@github-actions github-actions Bot added the pass label Oct 23, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit be48393 Oct 23, 2026
12 checks passed
@Birfy
Birfy deleted the feat/825-group-mention-answers branch October 23, 2026 09:46
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A friend's Bot @-mentioned in a group answers in that group, and its lines can call another participant's Bot

1 participant