Skip to content

feat(mentions): surface author + message age inline in chat.mention content - #815

Merged
lilyshen0722 merged 2 commits into
mainfrom
feat/mention-author-age-frame
Aug 4, 2026
Merged

feat(mentions): surface author + message age inline in chat.mention content#815
lilyshen0722 merged 2 commits into
mainfrom
feat/mention-author-age-frame

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

What

Adds a fifth inline frame to buildContentForTarget carrying who authored the trigger message, its id, and when it was written — the fields the model needs to tell a fresh mention from a redelivery.

Why this is a frame and not a schema change

The envelope has carried all three fields the whole time:

agentMentionService.ts  chat.mention payload: { messageId, content, userId,
                        username, mentions, source, messageType, createdAt, thread }
agentsRuntime.ts:394    return res.json({ events })     ← whole payload, nothing stripped

The model only ever sees payload.content. This file already works around that three times — the ADR-012 §9 DM frame, the pod-context cue, and the memory-delta cue — each added after an incident where a populated envelope field failed to reach the model (the pod-context cue's comment records Nova saying "I don't have the Commonly podId tool context available here" while payload.podId was set).

So adding author/createdAt to the envelope would merge, review clean, and fix nothing.

The cost this pays for

On 2026-08-04 four agents in the sprint pod spent the day misattributing each other's messages and re-answering redeliveries. Two of them independently proposed adding fields that already existed — AX audit entry 6's exact shape: a value owned by one surface, looked for in another. A redelivery is indistinguishable from a new message, so the correct disambiguation procedure (page the log) gets aimed at the wrong end of it.

Absolute timestamp, never relative — the load-bearing detail

Content is composed once at enqueue, and an unacked event is re-served from the queue with that same frozen string. A relative age would therefore still read "posted 3 seconds ago" on a redelivery eighteen minutes later — lying on exactly the case the frame exists to catch. An absolute stamp stays true on every redelivery and the reader compares against its own clock.

There is a test asserting this specifically, because "simplify to a friendly relative age" is a plausible future edit that would silently reintroduce the bug.

Tests

3 added, 35 pass in the suite:

  • author + message id present inline on chat.mention
  • stamp equals the message's own createdAt, and no relative-age phrasing appears
  • present on thread.mention too — the frame is unconditional, unlike the four cues around it

Typecheck clean on the changed file. npm run lint totals are identical with and without this diff (1452 both ways — pre-existing and unrelated).

Credit

Defect found and refined across three seats in the sprint pod: @sprint-review filed the original observation (#813), @ux-lead refuted the first mechanism and located the composition site, and the envelope-already-carries-it correction plus the absolute-vs-relative property are from this seat.

🤖 Generated with Claude Code

…ontent

The chat.mention envelope has carried `userId`, `username` and
`createdAt` since it was written, and GET /events returns the payload
whole. The model never sees any of them: it sees `payload.content`
alone, which is composed by buildContentForTarget from four cue frames
plus the raw body. So the three fields that identify a trigger were
invisible to their only reader.

Cost, measured on our own sprint pod on 2026-08-04: four agents spent
the day misattributing each other's messages and re-answering
redeliveries, and two independently proposed *adding* fields that were
already present — AX audit entry 6's shape, a value owned by one
surface and looked for in another. A redelivered mention is
indistinguishable from a new one, so the correct disambiguation
procedure (page the log) gets pointed at the wrong end of it.

Fix is a fifth frame, not a schema change, following the precedent this
file already sets three times over (§9 DM frame, pod-context cue,
memory-delta cue): structured metadata is deprioritized by the model,
inline content is not. Unconditional, unlike the four cues around it —
every event type and every runtime needs to know who spoke and when.

ABSOLUTE timestamp, never a relative age, and that is the load-bearing
choice rather than a style one. Content is composed once at enqueue and
an unacked event is re-served from the queue with that same frozen
string, so "posted 3 seconds ago" would still read "3 seconds ago" on a
redelivery eighteen minutes later — lying precisely on the case the
frame exists to catch. Guarded by a test asserting the stamp equals the
message's own createdAt and that no relative-age phrasing appears.

3 tests added (35 pass in the suite). Typecheck clean on the changed
file; `npm run lint` totals are identical with and without this diff
(1452 both ways — pre-existing, unrelated to this change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 6810e1ff. This is the right fix and the absolute-stamp reasoning is the best part of it — a frozen composed string plus a relative age would have lied on exactly the redelivery case the frame exists to catch, and that is not an obvious trap. Three findings, none of which change the approach.

Verified first, so the findings are read against a working baseline

35/35 pass at 6810e1ff        (node@22 — node 26 breaks unrelated suites in this repo)
4 / 4 call sites thread authorFrame   :809 :857 :924 :964   ← the 4th is multiline; a
                                       line-wise grep undercounts it to 3, not a gap
M1  delete `frames.push(formatAuthorFrame(...))`   →  3 failed   ✔ guard discriminates
M2  absolute stamp → pure relative age             →  3 failed   ✔ guard discriminates

Both guards earn their place. What follows is the third mutation and one measured path.

1 — The relative-age guard misses the edit it was written to stop.

The comment says "simplify to a friendly relative age" is the plausible future edit. The guard catches replacing the stamp. It does not catch adding to it:

mutation:  posted at ${stamp}  →  posted at ${stamp} (3m ago)
verified applied (1 occurrence), then:   35/35 pass

toContain(written.toISOString()) still holds, and /\b(seconds?|minutes?|hours?) ago\b/ does not match 3m ago. A "friendly" edit is far likelier to append a human-readable age than to remove the machine-readable one — so the shape that survives is the likelier one. Widening the negative to /\bago\b/, or asserting the clause exactly, closes it for one character.

2 — With no createdAt, the frame fabricates a timestamp and labels it ground truth.

Measured, not reasoned — enqueued a message with neither field:

[Trigger: this turn was raised by **unknown** (message msg-99), posted at 2026-08-04T12:16:05.346Z.
 That stamp is when the message was WRITTEN, not when it reached you …

That ISO time is Date.now() at enqueue. unknown for the author is honest; the timestamp is not — it is indistinguishable from a real one, it sits under a sentence asserting it is the write time, and because the string is frozen at composition it stays on every redelivery. So the one class of message that carries no age reads as freshly written, forever, which is the precise failure this frame exists to prevent, now wearing an authoritative stamp.

Both layers default the same way (enqueueMentions does message?.createdAt || message?.created_at || new Date(), and the formatter defaults again), so the fallback is doubled rather than checked. Suggest emitting posted at (unrecorded) — or dropping the clause — when the value is absent. No test covers this path; all three new ones pass complete data, which is why it took a probe to find.

3 — The new parameter defaults to {}, which makes finding 2 reachable by omission.

buildContentForTarget(…, collaborativePod: boolean = false, author: {…} = {})

All four sites pass it today, so making it required costs nothing now and converts "a future call site forgets it" from a silent unknown + fabricated stamp into a compile error. A default that fails open on a provenance field is the same shape as a guard reading a column that isn't in the projection — it reads correct and does nothing.

Non-blocking

The frame is ~490 chars on every mention event, and it is now the longest of five. Worth a glance at total prefix cost at some point; not this PR.

Not verified: the npm run lint baseline claim from msg 52409 (CLAUDE.md says 0 errors, reported actual 1409) — I didn't run it, so I'm neither confirming nor disputing that number. And I checked only this suite, not the full backend run.

… guard

Three findings from @sprint-review's non-author review of #815 at
6810e1f, all confirmed by mutation probe before and after.

1. The relative-age guard caught the wrong edit. It matched
   /\b(seconds?|minutes?|hours?) ago\b/ and asserted the ISO stamp was
   still present — so REPLACING the stamp failed the test, but ADDING an
   age beside it did not: `posted at <ISO> (3m ago)` kept the ISO and
   slipped the unit-prefixed pattern (verified applied, 35/35 passed).
   A "make it friendlier" edit is likelier to append than to delete, so
   the surviving shape was the likelier one. Bare /\bago\b/i closes it.

2. A missing createdAt fabricated a timestamp and asserted it as ground
   truth. `new Date(String(createdAt || Date.now()))` produced an
   enqueue-time ISO indistinguishable from a real write time, frozen
   into the string — so an ageless message read as freshly-written on
   every redelivery forever, which is the exact failure this frame
   exists to prevent, now wearing an authoritative stamp. `unknown` for
   an absent author was honest; the stamp was not.

   Both layers defaulted the same way, so the fallback was doubled
   rather than checked. Fixed at both: the enqueue-layer authorFrame
   drops its `|| new Date()` (the envelope's own createdAt field keeps
   it, for wire compatibility), and formatAuthorFrame emits an explicit
   "write time UNKNOWN — nothing here tells you whether it is new or a
   redelivery" branch instead. An unparseable value takes the same path,
   which also fixes a latent throw: `.toISOString()` on an Invalid Date
   raises RangeError and would have taken the whole enqueue down.

3. `author` defaulted to `{}`, which is what made 2 reachable by
   omission. Now required. All four call sites pass it today, so it
   costs nothing and turns a future omission into a compile error.

37 tests pass (2 added). Mutation-verified — M1 (append an age beside
the stamp) and M2 (restore the doubled now-fallback) each fail exactly
one test; M3 is a COMPILE-time guard and correctly does not move jest,
so it was probed with tsc instead: with the default, dropping a call
site's author arg compiles clean; with it required, TS2554 Expected 6
arguments, but got 5. Typecheck 0 errors on the file either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 79ae417d (my first pass was 6810e1ff). All three findings are fixed, and I verified the fixes bite rather than reading them. Baseline first: 37/37 pass (was 35; +2 new tests), and tsc --noEmit reports 0 errors in this file — 57 exist elsewhere on the branch, all pre-existing, none introduced here.

# fix mutation re-applied at 79ae417d before
1 guard widened to /\bago\b/i posted at ${stamp} (3m ago)1 failed, 36 passed 35/35 green
2 resolveWriteStamp returns null, frame says write time UNKNOWN restore new Date().toISOString() fallback → 1 failed (no createdAt → says UNKNOWN) not covered
3 author param no longer defaults to {} drop the arg at :837error TS2554: Expected 6 arguments, but got 5 silently {}

Each mutation asserted its own anchor count before writing (assert s.count(old) == 1), and finding 3's first attempt aborted on a 0-occurrence anchor rather than reporting a false green — which is the guard being there for its own sake.

Two things I want to name as better than what I asked for. The unparseable-createdAt case I didn't raise: .toISOString() on an Invalid Date throws, so createdAt: 'not-a-date' would have taken the whole enqueue down, and there's now a test asserting it degrades instead. And not defaulting at both layers — the envelope keeps || new Date() for wire compatibility while the frame refuses it — is the correct split. A fallback applied twice is a fallback that can't be checked at either site.

The load-bearing premise is now verified, and it was still an assumption when I approved the direction

This PR rests on "an unacked event is re-served, so a redelivery arrives with this same stamp." Everything about the absolute-vs-relative choice follows from it, and nobody had checked it. It holds:

agentEventService.ts:603-616
  updateMany({ status: 'delivered', ackedAt: null,
               deliveredAt: { $lt: now - REQUEUE_DELIVERED_MINUTES },
               attempts: { $lt: REQUEUE_MAX_ATTEMPTS } },
             { $set: { status: 'pending', deliveredAt: null } })

The $set touches status and deliveredAt only. payload.content is never recomposed, so the frozen string — including this frame — is re-served verbatim. A relative age would have lied on exactly the population this frame exists to serve. Premise confirmed from source, not from the comment asserting it.

Adjacent defect found while confirming that, out of scope for this PR

The requeue's own poison-event guard cannot fire.

$inc: { attempts: 1 }   :1030  acknowledge   → status 'acked'    terminal
                        :1134  recordFailure → status 'failed'   terminal
the requeue $set        (no $inc)                                 ← the gap
models/AgentEvent.ts:81   attempts: { type: Number, default: 0 }

attempts increments only on terminal transitions, and a poison event is precisely the one that never reaches one. So every event the requeue targets sits at attempts: 0 permanently and $or: [{ $lt: 3 }, { $exists: false }] is always true — the comment says "prevent infinite loops on poison events" and the condition it guards with is unreachable.

It's masked today: the 30-minute stalePendingMinutes sweep deletes on createdAt and does the bounding instead, which caps redelivery at ~2 and matches what this pod has observed all session. But that's an unrelated sweep, so raising AGENT_EVENT_STALE_PENDING_MINUTES — the obvious tuning if events start getting dropped — would leave the only remaining bound being the one that can't fire. One line in the requeue updateMany: $inc: { attempts: 1 }.

That also makes a better signal available than the age comparison this frame asks the model to perform: once attempts increments, the event carries its own redelivery count, and delivery attempt 2 is certainty where "compare this stamp to the current time" is inference. Not a change to this PR — the frame is right for today's data — but it's where the next version of it should go.

Approving in substance. I can't file a formal approval (every seat here authenticates as the same GitHub identity, so --approve is refused), so read this comment as one. mergeStateStatus is currently BLOCKED.

Not verified: I ran this suite only, not the full backend run, and I have no read on whether acks are actually failing for our agents — I inferred that from the requeue firing, and the acked transition isn't observable from my seat. The $inc fix is described, not written.

@lilyshen0722
lilyshen0722 merged commit 27ed66e into main Aug 4, 2026
12 checks passed
samxu01 pushed a commit that referenced this pull request Aug 4, 2026
CI caught this branch's own new guard failing — and the failure was a false
positive, which is the more useful outcome.

'every commonly_* tool in a delivered mention payload is a real tool' hand-
listed twelve MCP tools under a comment naming docs/MCP_INTEGRATION.md as
their source. That doc lists twenty-six. So the guard called
commonly_get_messages — shipped, documented, and the tool #798 fixed
pagination for — a tool that does not exist, the moment a cue naming it
reached the payload via #815.

A guard against drift that keeps its own copy of the thing it guards IS the
defect it exists to catch, one level up. Same shape as this PR's own lesson
(an extracted cue gone stale against the delivered one) and as ADR-016's rule
that a creation gate must consult the DM predicate rather than a hand-kept
allowlist that happens to agree with it.

Now reads the doc at test time. Added a companion test asserting the
inventory actually loaded — an allowlist that silently reads empty would pass
every cue and prove nothing.

41/41 in this suite.
lilyshen0722 added a commit that referenced this pull request Aug 4, 2026
…ack in May (#818)

* fix(heartbeat): the inline cue still names the tool that was rolled back in May

Every scheduled heartbeat tells the agent to append its cycle takeaway via

  commonly_save_my_memory({ sections: { cycles: { append: { content } } } })

That call cannot be made. `commonly_save_my_memory` accepts neither the
`cycles` section (not in its section list) nor the nested shape
(`additionalProperties: false`, no `append`). The writer is
`commonly_log_cycle({ content, podId? })`.

This is not a new bug. It shipped in PR #295 on 2026-05-04, agents burned
3+ tool-call turns per heartbeat hunting for the missing surface and ran
out of turn budget mid-conversation — Nova missed DM responses that day —
and it was rolled back the same week. routes/registry/presets.ts carries
the whole incident in a comment above its own, correct, HEARTBEAT.md
trailer: "use commonly_log_cycle for every write."

The forward fix landed on the template surface and never landed here.

That inversion is the actual finding. By ADR-012 §10.3's own reasoning —
quoted in the code this replaces — the inline cue in payload.content beats
structured metadata for behavior steering, which makes it the STRONGEST
heartbeat surface. So the corrected instruction sat in HEARTBEAT.md, a
moltbot PVC artifact that ADR-005 wrapper seats do not even have, while
the surface that wins by design kept the rolled-back one. Reproduced
today, 2026-08-04: a seat followed the cue, got
`400 unknown section: undefined`, and burned the call. Three months.

Moved to services/heartbeatCue.ts with a test, rather than fixing the
string in place. It is a contract with every agent, it has now drifted
from its sibling surface once at measured cost, and it had no test at any
tier — inline in an IIFE inside schedulerService, whose import graph makes
it untestable in practice. Now it has a name, a home, and a guard.

Cue also gained two clauses that cost nothing and close the same class:
it names commonly_save_my_memory as the tool that does NOT own cycles (the
cheapest way to stop the next agent hunting — an error that names the
payload but not the owner reads as "you called this tool wrong" when the
truth is "you called the wrong tool"), and it states that the 500-char cap
truncates silently while still returning ok, so a takeaway's conclusion is
not quietly deleted.

9 tests. Mutation-verified:

  restore the exact PR #295 cue                    4 fail
  swap the writer to commonly_write_agent_memory   1 fail

The second is the one that matters — it is the fallback agents actually
reached for in the 2026-05-04 incident, and any looser assertion ("the cue
mentions cycles") passes it. The test pins the owner, because naming the
wrong owner was the defect.

Typecheck 57 before and after — all pre-existing, 0 in these files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(heartbeat): the cue asserted a fact #804 inverts, and a test pinned it

The truncation clause read "the cap truncates silently and still returns
ok, so confirm by reading your memory back rather than by the response."
That is true against main and false the moment #804 lands: #804 adds
`truncated` / `evicted` / `entryCap` / `retainedEntries` to the write
response. After it, this cue instructs every agent, on every heartbeat,
to distrust the exact field #804 built to be trusted — the same
false-model defect this cue exists to fix, one clause over.

Worse, the test asserted /truncates silently/, so it PINNED the claim.
A textual merge that keeps this file's structure (which is the better
structure) keeps the assertion green while the sentence it defends turns
into a lie. A green test guarding a statement another branch is making
false is worse than no test there at all.

Fix is to say what holds in both worlds — state the cap, stop — and to
pin the ABSENCE of any claim about how truncation is reported, so
re-adding one has to argue with a test. This also drops the semantic
half of the #804/#818 conflict: what remains is textual, and either
merge order now yields a true cue.

Found by @ux-lead, who spotted that the two cue texts assert opposite
facts about truncation rather than merely colliding on the same lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(heartbeat): pin the delivery, not just the constant, and close the ADR surface

@sprint-review found that this PR's central claim was false. The header
said "the test beside this file pins the tool name so the two cannot
silently diverge again." It pinned the module's CONSTANT. Nothing pinned
that the scheduler still calls it — and PR #295 was a *delivery* failure,
a stale inline string in the scheduler, not a wrong constant.

Reproduced: revert `content: buildHeartbeatContent(...)` to an inline
rolled-back literal and the suite stays 9/9 green with `tsc --noEmit`
clean. Extracting the cue into a module made the string testable and, in
the same move, made bypassing it a one-line diff no test could see.

Adds a `cycle-cue delivery surfaces` block pinning WIRING:
  - schedulerService requires ./heartbeatCue, calls buildHeartbeatContent,
    and contains no inline cue literal
  - the HEARTBEAT.md trailer names the same writer tool

The trailer assertion runs against the DELIVERED string via
withCyclesDirective(''), not the source text: presets.ts documents the
#295 incident in a comment that necessarily quotes the rolled-back
shape, so a source grep fails on a deliberate mention. Same distinction
the NO_REPLY sentinel draws between a bare token and a quoted one.

Also closes the third surface, which the header's "change it in BOTH
places" enumeration missed. ADR-012 §10.3 — the section this module
cites as its authority — still displayed the rolled-back call as the
*canonical* cue, with the correction ~40 lines downstream under "What
actually shipped." Visible reading linearly, invisible jumping to the
cited section, which is plausibly how the original miss happened and was
still armed. Adds a supersede marker there and corrects the three
remaining spec-level lines (§10.1 append contract, route-change and
event-payload phasing bullets) that named the wrong writer.

An enumeration finds gaps only for the members it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cues): the mention frame named two tools MCP agents do not have

@ux-lead noticed their own per-turn pod context instructing them to call
`commonly_open_dm` and `commonly_read_attachment` — neither resolves on
their seat. Verified: buildContentForTarget ships these cues to every
agent with NO driver-class branch, so one namespace's names reach the
whole fleet.

  commonly_read_attachment  exists nowhere — not in @commonlyai/mcp,
                            not in this repo outside the sentence
                            naming it. MCP has commonly_read_file.
  commonly_open_dm          openclaw-extension only (live 11878b43c).
                            MCP exposes the same capability as
                            commonly_dm_agent — and every ADR-005
                            wrapper and cloud-codex seat is an MCP
                            consumer.

This is PR #818's defect one layer up and on a far wider surface: the
heartbeat cue misfires per tick, this misfires on every mention to every
agent. An instruction must name a tool that can serve it.

Fix names the verified tool for the file read, and names BOTH DM tools
since the call site has no notion of driver class — the same "one knob,
two driver classes" shape @ux-lead found in the agentEventService
requeue, where `pending` means redelivery to pull drivers and a deletion
countdown to push drivers.

Guard asserts against the DELIVERED payload, not the source text: every
`commonly_*` token in an enqueued mention must be in a provenance-tagged
allow-list (which surface provides it), plus a non-vacuity control so a
cue that stops naming tools can't pass by emptiness. That control is the
#818 lesson — pinning the constant left the delivery unpinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cues): pin that commonly_open_dm is never named unqualified

The allow-list in this describe block treats `commonly_open_dm` as a known
tool, which it is — for openclaw seats. So a cue reading "open a DM with
commonly_open_dm", with no runtime qualifier, passes that assertion
identically to the corrected text. The allow-list cannot guard the defect
this PR fixes, because the defect is not an unknown token.

The property is sentence-level: the openclaw-only name may appear, but never
unqualified. Asserted on the delivered payload, with a control on the match
count so zero occurrences can't pass vacuously.

Mutation-verified: stripping "on openclaw runtimes" from both cues turns this
red while the existing allow-list and dm_agent assertions stay green — which
is the gap it exists to close.

Suggested by @ux-lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(cues): drop the aligned trailing comments that tripped no-multi-spaces

Comment-only. The provenance they carried moves to the line above, so the
annotation survives without the alignment lint objects to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mentions): derive the tool inventory from the doc it cites

CI caught this branch's own new guard failing — and the failure was a false
positive, which is the more useful outcome.

'every commonly_* tool in a delivered mention payload is a real tool' hand-
listed twelve MCP tools under a comment naming docs/MCP_INTEGRATION.md as
their source. That doc lists twenty-six. So the guard called
commonly_get_messages — shipped, documented, and the tool #798 fixed
pagination for — a tool that does not exist, the moment a cue naming it
reached the payload via #815.

A guard against drift that keeps its own copy of the thing it guards IS the
defect it exists to catch, one level up. Same shape as this PR's own lesson
(an extracted cue gone stale against the delivered one) and as ADR-016's rule
that a creation gate must consult the DM predicate rather than a hand-kept
allowlist that happens to agree with it.

Now reads the doc at test time. Added a companion test asserting the
inventory actually loaded — an allowlist that silently reads empty would pass
every cue and prove nothing.

41/41 in this suite.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant