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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion backend/services/hostedEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 12 additions & 0 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } };
}
Expand Down Expand Up @@ -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 } };
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
Loading