From 4427de9e2c039f038de8af94946176118f259a21 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:29:16 -0700 Subject: [PATCH] fix(native): an agent a person @-mentioned never stands down to another agent's claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 from the out-of-box QA walk 4 (2026-10-16, message 78340): a stranger's first @-ask to their hosted agent got no answer. Scout's wake-on-message turn claimed the message first, answered NO_REPLY (the claim went declined), and the hosted agent, which had already lost the CAS, had stood down; the hand-off only re-offers ambient wakes. - Claims. The native runtime stood down on ANY lost claim. The CLI wrapper has always held the ADR-018 D3 rule that losing a CAS while DIRECTLY ADDRESSED is not a stand-down: a person chose this agent. Native now matches it for chat.mention, thread.mention, dm.message and a message that replies to the agent's own. The turn runs unclaimed. It gets the wrapper's peer-aware notice only when the holder was addressed by the same message too (an addressed event for it); beaten by an ambient listener, or unable to tell, it simply answers. - Meter. On the native engine the hosted meter counts runs, not acked events, so an event acked without a run (a stand-down) is no longer a used turn. - Greeting. Walk 4 had two posts again: the first-contact run's second round posted the "(Mention me as @…)" line on its own. A hosted first-contact run now ends at its first post, and the cue says the line ends that same message. - The provenance lookups added in #2249 (own user, trigger sender) run only when the turn can fetch. With the claims suite's Mongo reads mocked, that suite runs in 1.2 s instead of about 220 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- backend/__tests__/unit/routes/hosted.test.js | 2 + .../unit/services/firstContactService.test.js | 3 +- .../services/hostedRuntimeService.test.js | 19 ++++ .../nativeRuntimeService.claims.test.js | 93 ++++++++++++++++++- .../nativeRuntimeService.hostedEngine.test.js | 28 ++++++ backend/routes/hosted.ts | 2 +- backend/services/firstContactService.ts | 5 +- backend/services/hostedRuntimeService.ts | 21 ++++- backend/services/nativeRuntimeService.ts | 84 ++++++++++++++++- 9 files changed, 245 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index 1cca7a476b..852e48bf48 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -543,6 +543,8 @@ describe('/api/hosted on the native engine', () => { expect(res.status).toBe(200); expect(res.body.runtime).toEqual({ engine: 'native', ready: true, provisionedAt: PROVISIONED.toISOString() }); expect(res.body.meter).toMatchObject({ used: 3, cap: 200 }); + // A native turn is a run: an event acked without one (a stand-down) is not a turn. + expect(mockHosted.meterAllowsTurn).toHaveBeenCalledWith('scout', 'default', expect.any(Number), { countRuns: true }); expect(mockHosted.getAgentStatus).not.toHaveBeenCalled(); }); diff --git a/backend/__tests__/unit/services/firstContactService.test.js b/backend/__tests__/unit/services/firstContactService.test.js index 843449aa3c..c93beadd53 100644 --- a/backend/__tests__/unit/services/firstContactService.test.js +++ b/backend/__tests__/unit/services/firstContactService.test.js @@ -80,7 +80,8 @@ describe('firstContactService', () => { const { content } = AgentEventService.enqueue.mock.calls[0][0].payload; expect(content).toMatch(/exactly ONE specific, answerable question/); - expect(content).toMatch(/add one more line, exactly: "\(Mention me as @cto-walk so I see your answer\.\)"/); + expect(content).toMatch(/End that same message, after the question, with one more line, exactly: "\(Mention me as @cto-walk so I see your answer\.\)"/); + expect(content).toMatch(/post one message, not two/); }); test.each([ diff --git a/backend/__tests__/unit/services/hostedRuntimeService.test.js b/backend/__tests__/unit/services/hostedRuntimeService.test.js index b191fe3c92..4f3369b2d6 100644 --- a/backend/__tests__/unit/services/hostedRuntimeService.test.js +++ b/backend/__tests__/unit/services/hostedRuntimeService.test.js @@ -10,6 +10,10 @@ jest.mock('../../../models/AgentRegistry', () => ({ find: (...args) => mockInstallationFind(...args), }, })); +const mockRunCount = jest.fn(); +jest.mock('../../../models/AgentRun', () => ({ + countDocuments: (...args) => mockRunCount(...args), +})); jest.mock('../../../models/AgentEvent', () => ({ countDocuments: (...args) => mockEventCount(...args), })); @@ -25,6 +29,7 @@ describe('hostedRuntimeService', () => { mockInstallationCount.mockReset(); mockInstallationFind.mockReset(); mockEventCount.mockReset(); + mockRunCount.mockReset(); global.fetch = jest.fn(); }); afterEach(() => { @@ -96,6 +101,20 @@ describe('hostedRuntimeService', () => { expect(select).toHaveBeenCalledWith('agentName instanceId podId'); }); + // QA walk 4: an addressed turn that stood down to a peer's claim was acked + // with no run, and still showed as a used turn. On the native engine the + // meter counts runs. + it('on the native engine, meters runs since the UTC day start, leaving out the greeting', async () => { + process.env.HOSTED_TURNS_PER_DAY = '2'; + mockRunCount.mockResolvedValue(1); + const meter = await hosted.meterAllowsTurn('Scout', 'default', 2, { countRuns: true }); + expect(meter).toMatchObject({ allowed: true, used: 1, cap: 2 }); + expect(mockEventCount).not.toHaveBeenCalled(); + const filter = mockRunCount.mock.calls[0][0]; + expect(filter).toMatchObject({ agentName: 'scout', instanceId: 'default', trigger: { $ne: 'first_contact' } }); + expect(filter.startedAt.$gte.toISOString()).toMatch(/T00:00:00\.000Z$/); + }); + it('meters acked events by ACK time (deliveredAt) since the UTC day start and reports the reset', async () => { process.env.HOSTED_TURNS_PER_DAY = '2'; mockEventCount.mockResolvedValue(1); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js b/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js index 4e977c5368..d7a33fcab6 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.claims.test.js @@ -6,8 +6,10 @@ * * Contract under test: * - message-shaped triggers claim payload.messageId before any run/LLM work - * - a lost CAS stands down: no AgentRun row, no LLM call, no typing, and - * the result reads as a successful (acked) no-op + * - a lost CAS on an AMBIENT trigger stands down: no AgentRun row, no LLM + * call, no typing, and the result reads as a successful (acked) no-op + * - a lost CAS while DIRECTLY ADDRESSED runs anyway (the wrapper's rule), + * peer-aware only when the holder was addressed too * - a won claim runs, then releases in the same cleanup that stops typing * - claim infrastructure failure fails OPEN (#887) — the run proceeds * - triggers without a message (heartbeat) never touch the claim service @@ -48,6 +50,27 @@ jest.mock('../../../services/messageClaimHandoffService', () => ({ release: jest.fn(), })); +// No outsider-writable grant, no connector tools, and no User rows. The real +// modules read Mongo, which unit mode does not connect, so every turn here +// waited out mongoose's 10 s buffer per read (about 20 s an arm before). +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); +jest.mock('../../../models/User', () => ({ + findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), + findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), +})); +jest.mock('../../../services/grantBrokerProjectionService', () => ({ + hostedBrokerToolsForRun: jest.fn().mockResolvedValue({ tools: [], dispatch: new Map() }), + dispatchHostedBrokerTool: jest.fn().mockResolvedValue(null), +})); + +// Whether the holder of a lost claim was itself addressed by the message. +const mockHolderEvent = jest.fn(); +jest.mock('../../../models/AgentEvent', () => ({ + exists: (...args) => mockHolderEvent(...args), +})); + const axios = require('axios').default; const AgentRun = require('../../../models/AgentRun'); const MessageClaimService = require('../../../services/messageClaimService'); @@ -123,9 +146,15 @@ describe('nativeRuntimeService claim-before-act (ADR-018 D3)', () => { expect(typing.emitAgentTypingStop).toHaveBeenCalled(); }); - test('a lost CAS stands down: no run row, no LLM call, no typing, acked no-op', async () => { + const ambientTrigger = { + type: 'message.posted', + eventId: 'evt-2', + payload: { messageId: 'msg-77', content: 'thinking out loud' }, + }; + + test('a lost CAS on an ambient wake stands down: no run row, no LLM call, no typing, acked no-op', async () => { MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'task-clerk' }); - const result = await runAgent(installation, mentionTrigger); + const result = await runAgent(installation, ambientTrigger); expect(result).toEqual({ runId: '', status: 'succeeded', totalTurns: 0, totalTokens: 0, }); @@ -135,6 +164,62 @@ describe('nativeRuntimeService claim-before-act (ADR-018 D3)', () => { expect(MessageClaimService.release).not.toHaveBeenCalled(); }); + // QA walk 4 (2026-10-16, message 78340): Scout's ambient wake claimed a + // stranger's first @-ask to their hosted agent, answered NO_REPLY, and the + // hosted agent, having stood down, never ran. Addressed outranks a CAS. + const userMessageSent = () => axios.post.mock.calls[0][1].messages.find((m) => m.role === 'user').content; + + test.each([ + ['a mention', mentionTrigger], + ['a thread mention', { ...mentionTrigger, type: 'thread.mention' }], + ['a DM', { ...mentionTrigger, type: 'dm.message' }], + ['a reply to its own message', { ...ambientTrigger, payload: { ...ambientTrigger.payload, repliesToYourMessage: true } }], + ])('directly addressed by %s, a lost CAS still runs the turn, unclaimed', async (_label, trigger) => { + MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'scout', instanceId: 'scout-1' }); + mockHolderEvent.mockResolvedValue(null); + + const result = await runAgent(installation, trigger); + + expect(result.status).toBe('succeeded'); + expect(AgentRun.create).toHaveBeenCalledTimes(1); + expect(axios.post).toHaveBeenCalledTimes(1); + // Nothing was held, so nothing is released. + expect(MessageClaimService.release).not.toHaveBeenCalled(); + expect(MessageClaimHandoffService.release).not.toHaveBeenCalled(); + }); + + test('beaten by an ambient listener, the addressed agent just answers: no peer notice', async () => { + MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'scout', instanceId: 'scout-1' }); + mockHolderEvent.mockResolvedValue(null); + + await runAgent(installation, mentionTrigger); + + expect(mockHolderEvent).toHaveBeenCalledWith(expect.objectContaining({ + agentName: 'scout', instanceId: 'scout-1', 'payload.messageId': 'msg-77', + })); + expect(userMessageSent()).not.toMatch(/Claim notice/); + }); + + test('when the holder was addressed too, the turn runs peer-aware', async () => { + MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'task-clerk', instanceId: 'default' }); + mockHolderEvent.mockResolvedValue({ _id: 'evt-holder' }); + + await runAgent(installation, mentionTrigger); + + expect(userMessageSent()).toMatch(/^\[Claim notice: @task-clerk holds message msg-77 and was addressed too/); + expect(userMessageSent()).toMatch(/otherwise return NO_REPLY\.\]/); + }); + + test('if it cannot tell whether the holder was addressed, it answers rather than defers', async () => { + MessageClaimService.claim.mockResolvedValue({ claimed: false, claimedBy: 'task-clerk', instanceId: 'default' }); + mockHolderEvent.mockRejectedValue(new Error('mongo down')); + + await runAgent(installation, mentionTrigger); + + expect(AgentRun.create).toHaveBeenCalledTimes(1); + expect(userMessageSent()).not.toMatch(/Claim notice/); + }); + test('a human wake that ends NO_REPLY declines into the shared one-seat handoff', async () => { AgentMessageService.postMessage.mockResolvedValueOnce({ success: true, skipped: true }); const result = await runAgent(installation, { diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 3a0e72ef68..841a68bec8 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -342,6 +342,34 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => }); }); + // QA walk 4 (2026-10-16): given a second round, the greeting run posted its + // "(Mention me as @…)" line as a second message. A hosted greeting is one post. + test('a hosted first-contact run ends at its first post', async () => { + const AgentMessageService = require('../../../services/agentMessageService'); + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'commonly_post_message', { podId: POD, content: 'Hi! What should we start on?' }))) + .mockResolvedValueOnce(toolCalls(call('c2', 'commonly_post_message', { podId: POD, content: '(Mention me as @maya so I see your answer.)' }))); + + const result = await runAgent(hostedInstall(), { + type: 'first_contact', eventId: '507f1f77bcf86cd799439013', payload: { content: 'Greet them.', userId: OWNER }, + }); + + expect(result.status).toBe('succeeded'); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(savedRun.status).toBe('succeeded'); + }); + + test('a hosted mention run is not cut short by that rule', async () => { + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'commonly_post_message', { podId: POD, content: 'Here is the answer.' }))) + .mockResolvedValueOnce(toolCalls(call('c2', 'commonly_write_memory', { content: 'answered' }))); + + await runAgent(hostedInstall(), mention); + + expect(axios.post.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + test('in a pod holding an outsider-writable grant, a hosted turn is narrowed like any native one', async () => { const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); podHasOutsiderWritableGrant.mockResolvedValue(true); diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index 4edc35ca91..d60234ec8b 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -371,7 +371,7 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => { // and the runtime it needs is available (checked just above). const { provisionedAt } = readHostedConfig(installation); const { turnsPerDay } = await hostedRuntime.hostedCapsForOwner(getUserId(req)); - const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay); + const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay, { countRuns: true }); return res.json({ agentName, instanceId, diff --git a/backend/services/firstContactService.ts b/backend/services/firstContactService.ts index 7c9f0a957e..be132c891f 100644 --- a/backend/services/firstContactService.ts +++ b/backend/services/firstContactService.ts @@ -33,8 +33,9 @@ const FIRST_CONTACT_CUE = // The greeting's question invites a reply, and an agent that sees only its // mentions would never see a plain one. So that greeting says how to reach it. const mentionLineCue = (handle: string): string => ( - ` After the question, add one more line, exactly: "(Mention me as @${handle} so I see your answer.)" ` - + 'You only see messages that mention you, so without that line their answer would never reach you.' + ` End that same message, after the question, with one more line, exactly: "(Mention me as @${handle} ` + + 'so I see your answer.)" It is part of the greeting: post one message, not two. You only see ' + + 'messages that mention you, so without that line their answer would never reach you.' ); const MENTION_HANDLE = /^[a-z0-9][a-z0-9-]{0,63}$/; diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index ca7de55b9c..ad916def7c 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -156,6 +156,22 @@ export const turnsToday = async (agentName: string, instanceId: string): Promise }) ); +// On the native engine a turn is a run: an AgentRun is created only when the +// model is actually called. The event count above also counts an event that +// was acked without a run, such as an addressed turn that stood down to a +// peer's claim (QA walk 4, 2026-10-16: the ask got no answer and still showed +// as a used turn). The greeting is left out here too. +export const nativeTurnsToday = async (agentName: string, instanceId: string): Promise => { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const AgentRun = require('../models/AgentRun'); + return AgentRun.countDocuments({ + agentName: String(agentName).toLowerCase(), + instanceId: instanceId || 'default', + startedAt: { $gte: utcDayStart() }, + trigger: { $ne: 'first_contact' }, + }); +}; + export interface MeterResult { allowed: boolean; used: number; @@ -167,8 +183,10 @@ export const meterAllowsTurn = async ( agentName: string, instanceId: string, turnsPerDay: number = hostedCaps().turnsPerDay, + // True for a native-engine install, whose turns are runs, not acked events. + { countRuns = false }: { countRuns?: boolean } = {}, ): Promise => { - const used = await turnsToday(agentName, instanceId); + const used = countRuns ? await nativeTurnsToday(agentName, instanceId) : await turnsToday(agentName, instanceId); const tomorrow = new Date(utcDayStart().getTime() + 24 * 60 * 60 * 1000); return { allowed: used < turnsPerDay, @@ -269,6 +287,7 @@ module.exports = { countHostedAgentsForUser, listHostedInstallationsForUser, turnsToday, + nativeTurnsToday, meterAllowsTurn, HostedRuntimeError, provisionAgent, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 601fc9e88c..c9c721e466 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -913,6 +913,50 @@ const MENTION_RAW_TYPES = new Set([ 'chat.mention', 'thread.mention', 'dm.message', 'mention', 'chat.message', ]); +// The event types that mean a person addressed THIS agent, the wrapper's +// ADDRESSED_EVENT_TYPES (cli/src/lib/enforcement.js), plus a message that +// replies to the agent's own (the backend stamps repliesToYourMessage). +const ADDRESSED_RAW_TYPES: ReadonlySet = new Set(['chat.mention', 'thread.mention', 'dm.message']); + +export const wasDirectlyAddressed = (trigger: NativeRunTrigger): boolean => ( + ADDRESSED_RAW_TYPES.has(String(trigger.type)) + || (trigger.payload as { repliesToYourMessage?: unknown } | null)?.repliesToYourMessage === true +); + +/** + * Whether the agent holding a message's claim was itself addressed by it: it + * has an addressed event for that message. False on any failure, so an + * addressed turn that cannot tell answers rather than defers. + */ +export const holderWasAddressed = async ( + holder: string, + holderInstanceId: string, + messageId: string, +): Promise => { + try { + const AgentEvent = require('../models/AgentEvent'); + const found = await AgentEvent.exists({ + agentName: holder.toLowerCase(), + instanceId: holderInstanceId || 'default', + status: { $in: ['pending', 'delivered', 'acked', 'failed'] }, + createdAt: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, + type: { $in: [...ADDRESSED_RAW_TYPES] }, + 'payload.messageId': String(messageId), + }); + return Boolean(found); + } catch { + return false; + } +}; + +// The wrapper's peer-aware notice (cli/src/lib/enforcement.js peerHoldsFrame), +// for the case where the holder was addressed too. +const peerAddressedClaimFrame = (holder: string, messageId: string): string => ( + `[Claim notice: @${holder} holds message ${messageId} and was addressed too, so it is likely ` + + 'answering. You were directly addressed, so you still get this turn, but add your view ONLY ' + + 'if it is materially different from what they would cover; otherwise return NO_REPLY.]' +); + // Event types whose content is one pod or DM message, written by payload.userId. const SENDER_WRITTEN_EVENT_TYPES: ReadonlySet = new Set([...MENTION_RAW_TYPES, 'message.posted']); @@ -1362,6 +1406,9 @@ export async function runAgent( ? String((trigger.payload as { messageId?: unknown } | null)?.messageId || '') : ''; let claimHeld = false; + // Set when this turn lost the claim but was directly addressed and the + // holder was addressed too: the wrapper's peer-aware notice. + let peerClaimFrame = ''; if (claimMessageId) { try { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports @@ -1371,7 +1418,7 @@ export async function runAgent( }); if (claim?.claimed) { claimHeld = true; - } else if (claim) { + } else if (claim && !wasDirectlyAddressed(trigger)) { console.log( `[native-runtime] ${agentName}:${instanceId} stood down — message ${claimMessageId} ` + `claimed by ${claim.claimedBy || 'another agent'}`, @@ -1379,6 +1426,26 @@ export async function runAgent( return { runId: '', status: 'succeeded', totalTurns: 0, totalTokens: 0, }; + } else if (claim) { + // ADR-018 D3, the rule the CLI wrapper has always had: losing a CAS + // while DIRECTLY ADDRESSED is not a stand-down. A person chose this + // agent, and that outranks being beaten to the claim. Native stood + // down here unconditionally, so an ambient listener's claim silenced + // the agent a stranger had @-mentioned: Scout's wake-on-message turn + // claimed first, answered NO_REPLY, and the hosted agent never ran + // (QA walk 4, 2026-10-16, message 78340). The turn runs unclaimed. + // Only when the holder was addressed too does it get the peer-aware + // notice; beaten by an ambient listener, it just answers. + const holder = String(claim.claimedBy || ''); + const holderAddressed = holder + ? await holderWasAddressed(holder, String(claim.instanceId || 'default'), claimMessageId) + : false; + console.log( + `[native-runtime] ${agentName}:${instanceId} lost message ${claimMessageId} to ` + + `${holder || 'another agent'} — proceeding, this agent was directly addressed` + + `${holderAddressed ? ' (and so was the holder)' : ''}`, + ); + if (holderAddressed) peerClaimFrame = peerAddressedClaimFrame(holder, claimMessageId); } } catch (err) { console.warn('[native-runtime] claim unavailable — proceeding unguarded (#887):', (err as Error).message); @@ -1472,7 +1539,10 @@ export async function runAgent( ownUserId: null, givenAddresses: new GivenAddresses(), }; - dispatchCtx.ownUserId = await resolveOwnUserId(agentName, instanceId); + // 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). + const mayFetch = toolsForConfig(cfg).some((tool) => tool.function.name === 'commonly_fetch_url'); + if (mayFetch) dispatchCtx.ownUserId = await resolveOwnUserId(agentName, instanceId); // Read whenever a web lookup could matter: before each model call of a turn // that may search or read a page, and before a lookup or a post after one. A failed read @@ -1510,12 +1580,13 @@ export async function runAgent( const systemPrompt = buildSystemPrompt(installation, cfg); let userMessage = nativeCueFromKernel(buildUserMessage(trigger, podName), String(podId), NATIVE_TOOL_NAMES); + if (peerClaimFrame) userMessage = `${peerClaimFrame}\n${userMessage}`; const model = String(cfg.model || DEFAULT_MODEL); // TASK-267b: what commonly_fetch_url may open starts as the prompt and, // when a known human wrote it, the message this turn answers, before any // recent messages are added. dispatchCtx.givenAddresses.add(systemPrompt); - if (await triggerTextIsHumanWritten(trigger)) dispatchCtx.givenAddresses.add(userMessage); + if (mayFetch && await triggerTextIsHumanWritten(trigger)) dispatchCtx.givenAddresses.add(userMessage); const recentCount = Math.min(Math.max(Number(cfg.recentMessages) || 0, 0), RECENT_MESSAGES_MAX); if (recentCount > 0) { @@ -1806,6 +1877,13 @@ export async function runAgent( await run.save(); turnIndex += 1; + // A hosted agent's greeting is one post. Given another round, the + // model posted the greeting's last line as a second message (QA walk + // 4, 2026-10-16), so its first-contact run ends at its first post. + if (hostedNative && triggerType === 'first_contact' && postedViaTool) { + run.status = run.status === 'running' ? 'succeeded' : run.status; + break; + } continue; // run another LLM turn so the model can react to tool output }