From b9664ff4bebed55aca33768517cc45ca83c8866b Mon Sep 17 00:00:00 2001 From: Poytr1 Date: Sat, 15 Aug 2026 11:43:06 +0800 Subject: [PATCH] fix(daemon): a needsReply report resumes the parent session-only, never as a conversation post (#966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The niche half of the #966 scoping: replyToSession's local branch, the relay's lineage-reply branch, and the #800 inferred reply now stamp their delivery parentReport, and the #926 agent-wake inbound rendering (postAgentWakeInbound + its transcriptPostId mint) skips report deliveries — a child's private report into a webchat-conversation parent is injected into the parent's transcript and turn exactly as before, but is no longer committed as a live conversation post fanned to the roster. Measured before the fix: every role ack and night statement of the webchat Werewolf game ('We kill player-4 tonight.' included) was room-visible. Unmarked agent wakes keep the #926 behavior untouched (the existing daemon-webchat pin still passes verbatim). Sanctioned pin flips in the scripted games: postedPublicly false per reply, privateReportsPostedPublicly === 0 in both Werewolf games — previously pinned > 0 as the measured surface. The deeper half (report rows in the shared conversation transcript remain readable to peers' catch-up) stays open on #966 by design — it needs the report-row scoping decision documented there. Refs #966, #926. Co-Authored-By: Claude Fable 5 --- evals/test/webchat-night-collection.test.ts | 7 ++++--- evals/test/webchat-werewolf.test.ts | 11 +++++------ packages/daemon/src/daemon.ts | 10 ++++++++-- packages/daemon/src/messages/normalized.ts | 11 +++++++++++ packages/daemon/src/webchat/transport.ts | 2 ++ 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/evals/test/webchat-night-collection.test.ts b/evals/test/webchat-night-collection.test.ts index 1ac2eaa34..09a859edd 100644 --- a/evals/test/webchat-night-collection.test.ts +++ b/evals/test/webchat-night-collection.test.ts @@ -145,9 +145,10 @@ describe('webchat night collection (scripted)', () => { expect(outcome.mode, `${child} reply must reach the referee`).not.toBe('lost') expect(outcome.ownTurnStarts, `${child} reply must not double-wake the referee`).toBeLessThanOrEqual(1) expect(outcome.deliveredPromptSightings + outcome.contextRowSightings).toBeGreaterThanOrEqual(1) - // Current-main surface truth (#926): the child's "private" needsReply - // report is ALSO posted live into the conversation view for everyone. - expect(outcome.postedPublicly).toBe(true) + // #966 fixed (was the measured #926 surface): a needsReply report + // resumes the parent session-only — it is never committed as a + // conversation post, so the roster cannot read private reports. + expect(outcome.postedPublicly).toBe(false) } // The #800 mechanism-fix cell (formerly the #905 validation cell, whose diff --git a/evals/test/webchat-werewolf.test.ts b/evals/test/webchat-werewolf.test.ts index 7dd0a419f..7a624c2e6 100644 --- a/evals/test/webchat-werewolf.test.ts +++ b/evals/test/webchat-werewolf.test.ts @@ -78,12 +78,10 @@ describe('webchat werewolf (scripted)', () => { // player whose role does not hold it. expect(result.canaryCrossVisibility).toBe(0) - // Current-main surface truth (#926), measured and pinned: a child's - // needsReply REPORT into the conversation-origin parent session is - // posted live into the conversation view — webchat "private" night - // traffic is visible to the whole room. The Slack composition (private - // den + DMs) structurally hides this. - expect(result.privateReportsPostedPublicly).toBeGreaterThan(0) + // #966 fixed (was the measured #926 surface, previously pinned > 0): a + // needsReply report resumes the parent session-only — no role ack, kill + // statement, or night action ever surfaces as a conversation post. + expect(result.privateReportsPostedPublicly).toBe(0) }, 180_000) it('a 6-player game runs multiple rounds through the host night-cue loop (seed 2)', async () => { @@ -93,6 +91,7 @@ describe('webchat werewolf (scripted)', () => { expect(result.winner).toBeDefined() expect(result.canaryLeaks).toBe(0) expect(result.canaryCrossVisibility).toBe(0) + expect(result.privateReportsPostedPublicly).toBe(0) // Multi-round means at least two night cue round-trips through the host. expect(result.nights.length).toBeGreaterThanOrEqual(2) // Every night's kill was mediated: a proposal preceded the kill. diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 342e2c654..2187d74d9 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -6509,7 +6509,10 @@ export class Daemon { text: msg.text, mentionedBots: replyIntegrationId && this.botUserIds[replyIntegrationId] ? [this.botUserIds[replyIntegrationId]!] : [], - isDm: false + isDm: false, + // #966: a report resumes the parent session-only — never a live + // conversation post (postAgentWakeInbound skips report deliveries). + parentReport: true // §7: a lineage reply IS the cross-daemon parent-session reply, so it behaves like // the local branch of `replyToSession` — the injected report is transcript-only // (nothing here publishes it), and the resumed parent runs an ORDINARY turn that @@ -7344,6 +7347,9 @@ export class Daemon { transcriptTs: monotonicTs(), sender: { id: req.callerAgentId, isBot: true }, text: req.text, + // #966: a report resumes the parent session-only — never a live + // conversation post (postAgentWakeInbound skips report deliveries). + parentReport: true, mentionedBots: integrationId ? this.botUserIds[integrationId] ? [this.botUserIds[integrationId]!] @@ -11624,7 +11630,7 @@ export class Daemon { // REPLY, so the sender's message appeared on refresh but never in the live view). Mint // its canonical post identity before the inbox row persists so a replay reuses it and // the transcript row SessionManager writes carries the same postId (browser reconcile). - if (webchat?.initiator === 'agent' && UUID_RE.test(msg.sender.id)) { + if (webchat?.initiator === 'agent' && UUID_RE.test(msg.sender.id) && msg.parentReport !== true) { msg.transcriptPostId ??= randomUUID() } if (msg.sender.avatarUrl && msg.transportScope) diff --git a/packages/daemon/src/messages/normalized.ts b/packages/daemon/src/messages/normalized.ts index e23c9fda5..9785bcf90 100644 --- a/packages/daemon/src/messages/normalized.ts +++ b/packages/daemon/src/messages/normalized.ts @@ -66,6 +66,17 @@ export interface NormalizedMessage extends Omit< /** Trusted activation cause when known. In particular, `mention` means the router * matched a raw platform token against this integration's own bound bot identity. */ trigger?: 'mention' | 'dm' | 'keyword' | 'auto' | 'cron' | 'hook' + /** + * This delivery is a child session's REPORT into its parent (`sendMessage + * {sessionId}` — replyToSession's local branch, the relay's lineage-reply + * branch, and the #800 inferred reply). Set only by the daemon at those + * construction sites, never from ingress. A report resumes the parent + * SESSION-ONLY (#966): it is injected into the parent's transcript and + * turn, but is never committed as a live conversation post — the #926 + * agent-wake inbound rendering skips it, so a webchat conversation's roster + * does not see private night/task reports as room posts. + */ + parentReport?: boolean } /** diff --git a/packages/daemon/src/webchat/transport.ts b/packages/daemon/src/webchat/transport.ts index ee218b31a..e7774c451 100644 --- a/packages/daemon/src/webchat/transport.ts +++ b/packages/daemon/src/webchat/transport.ts @@ -512,6 +512,8 @@ export class WebchatTransport { * turn writes, so the browser drops the live step once the canonical row lands. */ postAgentWakeInbound(webchat: WebchatTurnContext | undefined, msg: NormalizedMessage): void { if (webchat?.initiator !== 'agent' || !webchat.postSink) return + // #966: a child's report resumes its parent session only — never a room-visible post. + if (msg.parentReport === true) return if (!msg.transcriptPostId || !UUID_RE.test(msg.sender.id)) return msg.transcriptTs ??= monotonicTs() // every wake site sets it; keep row ts == post.at regardless webchat.postSink({