From 5dd2915ac8ad453de8b5480ab7bc599a478a1b90 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:51 -0700 Subject: [PATCH] feat(native): every post names the message the turn answers; hosted prompt says it cannot change a task's status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../nativeRuntimeService.claims.test.js | 55 +++++++++++++++++++ .../nativeRuntimeService.hostedEngine.test.js | 2 + .../nativeRuntimeService.hostedOffer.test.js | 10 ++++ backend/services/hostedEngine.ts | 6 +- backend/services/nativeRuntimeService.ts | 12 ++++ 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js b/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js index d7a33fcab6..dc4e3450f8 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js @@ -251,4 +251,59 @@ describe('nativeRuntimeService claim-before-act (ADR-018 D3)', () => { await runAgent(installation, { type: 'heartbeat', eventId: 'evt-2', payload: {} }); expect(MessageClaimService.claim).not.toHaveBeenCalled(); }); + + // Walk 5 (2026-10-16): beaten to the claim, the addressed agent answered in + // 13 s and TASK-002 never completed, because the answer was credited only + // through a live claim. Every post the turn makes now names the message it + // answers, taken from the event. + describe('every post names the message the turn answers', () => { + const withPostTool = { ...installation, config: { ...installation.config, tools: ['commonly_post_message'] } }; + const postCall = (args) => ({ + status: 200, + headers: {}, + data: { + choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'commonly_post_message', arguments: JSON.stringify(args) } }] } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + }); + const done = { + status: 200, headers: {}, data: { choices: [{ message: { role: 'assistant', content: 'NO_REPLY' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }, + }; + + test('beaten to the claim, the tool post still carries the trigger id; the model cannot set it', async () => { + MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'scout', instanceId: 'scout-1' }); + mockHolderEvent.mockResolvedValue(null); + axios.post.mockResolvedValueOnce(postCall({ content: 'Here is the answer.', answeringMessageId: 'msg-999' })).mockResolvedValueOnce(done); + + await runAgent(withPostTool, mentionTrigger); + + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Here is the answer.', answeringMessageId: 'msg-77', + })); + }); + + test('the text fallback post carries it too', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, headers: {}, data: { choices: [{ message: { role: 'assistant', content: 'Plain answer.' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }, + }); + + await runAgent(installation, mentionTrigger); + + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Plain answer.', metadata: expect.objectContaining({ fallback: true }), answeringMessageId: 'msg-77', + })); + }); + + test('a turn no message woke answers no message', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, headers: {}, data: { choices: [{ message: { role: 'assistant', content: 'Heartbeat note.' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }, + }); + + await runAgent(installation, { type: 'heartbeat', eventId: 'evt-3', payload: {} }); + + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ content: 'Heartbeat note.' })); + expect(AgentMessageService.postMessage.mock.calls[0][0].answeringMessageId).toBeUndefined(); + }); + }); }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 841a68bec8..c2767ff276 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -316,6 +316,8 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => status: 'open', total: 3, tasks: [{ taskId: 'TASK-1', title: 'Say hello to the pod', status: 'pending', assignee: null }], }); expect(systemPromptIn(0)).toMatch(/check the board with commonly_list_tasks before saying what is or is not on it/); + // Walk 5: it offered to "mark TASK-002 done" with no tool that can. + expect(systemPromptIn(0)).toMatch(/you cannot change a task's status or mark one done: if asked to, say so/); // QA walk: a pod summary also searched the web and ran code (19 s). expect(systemPromptIn(0)).toMatch(/Answer a question about this pod from the pod itself; reach for the tools below only when/); // QA walk: an answer opening "@asker" filed every reply as "needs you". diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js index 3dc2c0f3b3..89aa7d7b01 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js @@ -98,6 +98,16 @@ describe('commonly_offer_hosted_agent', () => { expect(posted.payload.starts[0].href).toMatch(/^\/v2\/[^/\\]/); }); + test('the offer names the message it answers, from the event', async () => { + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('Posted the button above.')); + + await runAgent(scoutInstall, { ...ask, payload: { ...ask.payload, messageId: '5001' } }); + + expect(AgentMessageService.postMessage.mock.calls[0][0].answeringMessageId).toBe('5001'); + }); + test('posts nothing when the person already holds the beta\'s hosted agent, and names it', async () => { mockListHosted.mockResolvedValue([{ agentName: 'lee-agent', instanceId: 'default', podId: POD }]); axios.post diff --git a/backend/services/hostedEngine.ts b/backend/services/hostedEngine.ts index 9399fe52cc..1cc3a532f7 100644 --- a/backend/services/hostedEngine.ts +++ b/backend/services/hostedEngine.ts @@ -223,9 +223,13 @@ export const hostedNativeSystemPrompt = ( + 'when the mention alone is not ' + 'enough. Keep durable facts with commonly_write_memory, and track work with ' + 'commonly_create_task; check the board with commonly_list_tasks before saying what is or is ' + // Walk 5 (2026-10-16): the agent offered to "mark TASK-002 done", which + // no hosted tool can do. + + 'not on it. You can add tasks and read the board, but you cannot change a task\'s status or ' + + 'mark one done: if asked to, say so and point them to the pod\'s board. ' // QA walk 2026-10-16: asked to summarize the pod, a hosted agent also // searched the web and ran code, and the reply took 19 s instead of 7. - + 'not on it. Answer a question about this pod from the pod itself; reach for the tools below ' + + 'Answer a question about this pod from the pod itself; reach for the tools below ' + `only when the answer needs something outside it. ${uses.join(' ')} If no reply is genuinely ` + 'needed, do not post.\n' + '\n' diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index c9c721e466..2ceefc6a91 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -506,6 +506,14 @@ interface DispatchContext { * resolved, and null when it cannot be: then no pod message gives an address. */ ownUserId: string | null; + /** + * The message this turn answers: the trigger's own messageId, from the + * event, never from the model. Every post the turn makes carries it so the + * answer is credited to that message's author even when the turn runs + * unclaimed (an addressed agent that lost the claim race, #2269, or a claim + * service that failed open). It is not a reply edge and threads nothing. + */ + answeringMessageId?: string; } // The agent's own bot User, found the way the posting path derives it (the @@ -644,6 +652,7 @@ async function dispatchTool( // Web results are in this turn: the post stays in the pod and never // reaches a channel relay, whatever bridges exist when it is stored. noOutboundRelay: Boolean(ctx.webTainted), + answeringMessageId: ctx.answeringMessageId, }); return { content: { ok: true, posted: true, result } }; } @@ -688,6 +697,7 @@ async function dispatchTool( metadata: { source: 'native-runtime' }, payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: HOSTED_OFFER_LABEL, href }] }, noOutboundRelay: Boolean(ctx.webTainted), + answeringMessageId: ctx.answeringMessageId, }); return { content: { ok: true, posted: true, result } }; } @@ -1538,6 +1548,7 @@ export async function runAgent( installationConfig: cfg, ownUserId: null, givenAddresses: new GivenAddresses(), + answeringMessageId: claimMessageId || undefined, }; // Who wrote what matters only to commonly_fetch_url, so a turn that cannot // fetch makes neither lookup (its own user, the trigger's sender). @@ -1915,6 +1926,7 @@ export async function runAgent( installationConfig: cfg, metadata: { source: 'native-runtime', fallback: true }, noOutboundRelay: Boolean(dispatchCtx.webTainted), + answeringMessageId: dispatchCtx.answeringMessageId, }); // The message service suppresses a bare NO_REPLY (and other // silent output) without throwing. That is a normal declined turn,