Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions evals/test/webchat-night-collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions evals/test/webchat-werewolf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions packages/daemon/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]!]
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/daemon/src/messages/normalized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/daemon/src/webchat/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down