Skip to content

feat(native): every post names the message the turn answers; hosted prompt says it cannot change a task's status - #2273

Merged
lilyshen0722 merged 1 commit into
mainfrom
lily/hosted-answer-hook
Oct 16, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
lily/hosted-answer-hook

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Two findings from Cofounder's walk 5 (person-paced, 2026-10-16, with #2269 live). The addressed agent answered in 13.4 s despite losing the claim race to Scout, and the greeting was one post. Two small things remain.

1. Every native post names the message the turn answers

"Give your agent its first task" (TASK-002) never completed in walk 5. agentAnswerService.humansAnsweredBy credits an agent's post to the authors of messages it holds a live claim on, plus an explicit reply target. Since #2269, an addressed agent beaten to the claim runs unclaimed and posts with no reply edge, so its answer credits nobody. The same happens when the claim service fails open (#887).

The native run now passes answeringMessageId on every AgentMessageService.postMessage call it makes: the tool post, the hosted-offer post and the text fallback.

  • Source: the trigger's own payload.messageId (claimMessageId), from the event. A value the model puts in its tool arguments is ignored.
  • Scope: set only for message-shaped triggers, so a heartbeat carries none. Passed whether or not the claim was won; the claimed case dedupes.
  • Not a reply edge: it threads nothing (ux-lead's ruling in agentAnswerService).

The consumer is Growth's PR. It covers postMessage → completeFirstTaskOnAnswer and the attention row, pod-checks the id in humansAnsweredBy, and blocks the field from the CAP HTTP body. No ordering dependency: until that lands, postMessage ignores the field.

2. The hosted prompt says it cannot change a task's status

In walk 5 the agent offered "say the word and I'll mark TASK-002 done", and no hosted tool can do that. The prompt now says it can add tasks and read the board but cannot change a task's status or mark one done, and if asked should say so and point to the pod's board.

Tests

arm suite
beaten to the claim, the tool post carries the trigger id; a model-supplied answeringMessageId is ignored nativeRuntimeService.claims.test.js
the text-fallback post carries it same
a heartbeat's post carries none same
the hosted-offer post carries it nativeRuntimeService.hostedOffer.test.js
the prompt line nativeRuntimeService.hostedEngine.test.js

Mutations, each caught: ctx left unset (3 red); the model's value wins; the offer post drops it; the fallback drops it; the prompt line is removed.

Local runs: 599/599 across 40 suites (native runtime, hosted, first contact, agentMessageService, starter task, hosted routes). Type-check: the same 50 pre-existing errors in standalone scripts, none from this branch. ESLint is clean. No SQL changed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG

…rompt says it cannot change a task's status

Walk 5 (Cofounder, 2026-10-16, #2269 live). Two findings:

1. Beaten to the claim by Scout, the addressed hosted agent answered in
   13.4 s, and TASK-002 never completed. humansAnsweredBy credits an answer
   only through a live claim or a reply edge, and the unclaimed turn has
   neither. The native run now passes `answeringMessageId`, the trigger's
   own messageId taken from the event and never from the model's tool
   arguments, on all three posts it makes: the tool post, the hosted-offer
   post and the text fallback. It is not a reply edge and threads nothing.
   Growth's PR consumes it (postMessage → completeFirstTaskOnAnswer and
   attention, pod-checked in humansAnsweredBy). Until that lands,
   postMessage ignores the field; there is no ordering dependency.

2. The hosted agent offered to "mark TASK-002 done", which no hosted tool
   can do. The prompt now says it can add tasks and read the board but not
   change a task's status, and to say so.

Arms: a lost claim's tool post carries the trigger id and a model-supplied
value is ignored; the fallback post and the offer post carry it; a heartbeat
carries none; the prompt line. Mutations caught: ctx unset, model value
wins, offer drops it, fallback drops it, prompt line removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG

@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.

Written by sprint-review, a Commonly agent — code gate at 5dd2915ac. pod thread

Base d091d8c35. origin/main is now 95f7d4e35 and has touched nativeRuntimeService.ts since that base (3 insertions, via #2272), so I measured the merged state too, not just the branch.

The property holds, and "every post" is an enumeration rather than a reading

grep -n "postMessage(" services/nativeRuntimeService.ts returns exactly three call sites — :643 (the tool post), :689 (the hosted offer), :1919 (the text fallback) — and all three carry answeringMessageId. That matches the diff's three additions with nothing left over, which is what makes "every post a native turn makes" checkable rather than a claim about code I happened to read. Across the backend, the other AgentMessageService.postMessage callers (personaHireService, decisionRequestService, approvalActionService, agentMentionService, stalledConnectService) are not native turns, and hostedEngine.ts does not post at all.

Model-supplied values can't win structurally, not just by test. grep -n "\.\.\.args\|\.\.\.parsed\|\.\.\.input" over the file returns nothing: every field in all three option objects is written explicitly, and answeringMessageId is taken from ctx. The source is claimMessageId, which is String(trigger.payload?.messageId || '') gated on CLAIMABLE_RAW_TYPES, so it is the trigger's own payload id and undefined when there is no message trigger.

Seven mutations, each with the needle asserted

BASE and RESTORED both 3 suites / 44 passed:

mutation failures
V1 ctx never set 3 — beaten to the claim, the tool post still carries the trigger id; the model cannot set it, the text fallback post carries it too, the offer names the message it answers, from the event
V2 the tool post drops it 1 — the first of those
V3 the hosted-offer post drops it 1 — the offer arm
V4 the text fallback drops it 1 — the fallback arm
V5 the model's tool argument wins over ctx 1 — …the model cannot set it
V7 the hosted prompt sentence removed 1 — the board is read, not guessed
V6 the CLAIMABLE_RAW_TYPES gate removed green, 44/44

Each post site is pinned individually, so the three arms are not one assertion wearing three names.

Tier 1 at this exact head: run 38867627069, job Service Tests (Tier 1 — real DBs), headSha 5dd2915ac8ad453de8b5480ab7bc599a478a1b90, conclusion success — 53 suites / 532 tests. Every other check passes. merge-tree against current main: 0 conflicts, and merged with origin/main the five relevant suites (including the now-merged consumer agentAnswerService.test.js and starterTaskService) are 63/63. So producer and consumer agree on main's actual content, not just on the branch pair.

Non-blocking: the scope arm passes for a reason other than the gate

V6 is the one that didn't discriminate, and it's worth stating precisely because the behaviour is correct — it's the test that isn't load-bearing.

Deleting the raw-type gate so that claimMessageId becomes String(trigger.payload?.messageId || '') for any trigger type leaves all 44 tests green. The reason is the fixture: both heartbeat arms in nativeRuntimeService.claims.test.js (:251, :303) pass payload: {}, so gated and ungated code both produce ''. The arm proves the field is absent on a heartbeat; it does not prove the gate is what makes it absent. Supplying only the input on which the two behaviours agree is the one fixture shape that cannot fail.

I checked reachability before calling this a gap, and it is not live today. Every producer of payload.messageId I can find is a mention/message enqueue in agentMentionService (:1409 resolves chat.mention / thread.mention; :1865 and :2149 are literal 'chat.mention'), all inside CLAIMABLE_RAW_TYPES. The other messageId writes — agentEventService.normalizeDeliveryMeta and the :1924 ack — are delivery.messageId, not payload, and approvalActionService:519 is a socket emit. So no non-claimable event carries a payload id, the gate is defence in depth, and V6 is an equivalent mutant on current data rather than a reachable defect.

The fix is one character of fixture: give a heartbeat arm payload: { messageId: 'm-1' } and assert the post carries none. That converts the arm from "the field happens to be absent" into "the gate keeps it absent", and it's the arm that would catch a future event type that starts carrying a message id — which is exactly how this invariant would break.

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit d568d58 Oct 16, 2026
17 checks passed
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