diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index 026ec654c5..df9cf73559 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -77,4 +77,14 @@ describe('Scout onboarding knowledge', () => { expect(prompt).toMatch(/at most one question per turn/); expect(prompt).not.toMatch(/within the hour|within an hour|you will hear back|you'll hear/i); }); + + // QA 2026-10-16: Scout said "200 turns a day" while the card and the meter + // said 30, because the prompt kept its own copy of the plan line. The limits + // now reach Scout only through the hosted offer's description. + test('quotes no hosted limit from memory, and hands off to Run it here with a button', () => { + expect(prompt).not.toMatch(/\d+\s+turns (a|per) day/i); + expect(prompt).toContain('commonly_offer_hosted_agent'); + expect(scoutApp.tools).toContain('commonly_offer_hosted_agent'); + expect(prompt).toMatch(/with no invitation code/); + }); }); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a09d7ccc57..f8ef8f6d66 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -63,6 +63,8 @@ jest.mock('../../../services/hostedRuntimeService', () => mockHosted); const mockNativeAvailable = jest.fn(() => false); jest.mock('../../../services/nativeRuntimeService', () => ({ isNativeRuntimeAvailable: (...args) => mockNativeAvailable(...args), + // The real rule, over this file's two switches. + hostedEngineAvailable: (engine) => (engine === 'native' ? mockNativeAvailable() : mockHosted.isConfigured()), })); const mockMarkByoOpened = jest.fn(); jest.mock('../../../services/onboardingMarks', () => ({ markByoOpened: (...args) => mockMarkByoOpened(...args) })); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js new file mode 100644 index 0000000000..3dc2c0f3b3 --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js @@ -0,0 +1,133 @@ +/** + * Scout's hosted offer (QA 2026-10-16): asked "can Commonly host it?", Scout + * answered with text directions and no link, while the Bring-your-own path got + * a card. commonly_offer_hosted_agent posts the model's reply with one + * server-composed button to Run it here, aimed at this pod. + */ +jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock('../../../models/Pod', () => ({ + findById: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ name: 'My Workspace' }) })), +})); +jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +jest.mock('../../../models/User', () => ({ + findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), +})); +jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); +jest.mock('../../../services/messageClaimService', () => ({ + claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), + release: jest.fn().mockResolvedValue({ released: true }), +})); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); +const mockListHosted = jest.fn(); +jest.mock('../../../services/hostedRuntimeService', () => ({ + ...jest.requireActual('../../../services/hostedRuntimeService'), + listHostedInstallationsForUser: (...args) => mockListHosted(...args), +})); + +const axios = require('axios').default; +const AgentRun = require('../../../models/AgentRun'); +const AgentMessageService = require('../../../services/agentMessageService'); +const { runAgent } = require('../../../services/nativeRuntimeService'); + +const POD = '507f1f77bcf86cd799439011'; +const OWNER = '507f1f77bcf86cd799439099'; +const scoutInstall = { + podId: POD, + agentName: 'scout', + instanceId: 'u1234567890', + displayName: 'Scout', + installedBy: OWNER, + config: { runtime: { runtimeType: 'native' }, tools: ['commonly_post_message', 'commonly_offer_hosted_agent'] }, +}; +const ask = { + type: 'chat.mention', + eventId: '507f1f77bcf86cd799439012', + payload: { content: 'I\'d rather not install anything. Can Commonly host it for me?' }, +}; +const REPLY = 'Yes. Commonly can run it for you, nothing to install.'; + +const callsTool = (name, args) => ({ + status: 200, + headers: {}, + data: { + choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: JSON.stringify(args) } }] } }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +}); +const says = (content) => ({ + status: 200, + headers: {}, + data: { choices: [{ message: { role: 'assistant', content } }], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } }, +}); + +describe('commonly_offer_hosted_agent', () => { + const savedEnv = { ...process.env }; + beforeEach(() => { + jest.clearAllMocks(); + process.env.LITELLM_BASE_URL = 'http://litellm.test'; + process.env.LITELLM_MASTER_KEY = 'test-key'; + process.env.HOSTED_ENGINE = 'native'; + AgentRun.countDocuments.mockResolvedValue(0); + AgentRun.create.mockImplementation(async (doc) => ({ _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) })); + mockListHosted.mockResolvedValue([]); + }); + afterAll(() => { process.env = savedEnv; }); + + test('posts the reply once, with one Run it here button aimed at this pod with hosting chosen', async () => { + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('Posted the button above.')); + + const result = await runAgent(scoutInstall, ask); + + expect(result.status).toBe('succeeded'); + expect(mockListHosted).toHaveBeenCalledWith(OWNER); + // The narration after the tool is not posted beside it. + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + const posted = AgentMessageService.postMessage.mock.calls[0][0]; + expect(posted.content).toBe(REPLY); + expect(posted.podId).toBe(POD); + expect(posted.payload).toEqual({ + kind: 'starts', + starts: [{ id: 'run-hosted', label: 'Run it here', href: `/v2/agents/byo?pod=${POD}&mode=hosted` }], + }); + // The thread renders a start's href only as an in-app /v2/ path. + expect(posted.payload.starts[0].href).toMatch(/^\/v2\/[^/\\]/); + }); + + 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 + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('You already have lee-agent. Mention it here.')); + + await runAgent(scoutInstall, ask); + + const toolMessage = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(toolMessage.content).toMatch(/already have 1 hosted agent/); + expect(toolMessage.content).toContain('lee-agent'); + // Only the model's own follow-up reached the pod, with no button. + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage.mock.calls[0][0].payload).toBeUndefined(); + }); + + test('is not offered where the instance cannot host, so no dead button is posted', async () => { + delete process.env.HOSTED_ENGINE; + delete process.env.HOSTED_RUNTIME_URL; + delete process.env.HOSTED_RUNTIME_ADMIN_TOKEN; + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('Hosting is not available here.')); + + await runAgent(scoutInstall, ask); + + const offered = axios.post.mock.calls[0][1].tools.map((tool) => tool.function.name); + expect(offered).not.toContain('commonly_offer_hosted_agent'); + const toolMessage = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(toolMessage.content).toMatch(/not available to you in this turn/); + expect(AgentMessageService.postMessage.mock.calls.every(([arg]) => arg.payload === undefined)).toBe(true); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js index 9a78925494..252ae3871c 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js @@ -6,10 +6,37 @@ * filters exactly; an undeclared (legacy) config keeps the pre-gate surface * MINUS approval proposing, which is opt-in by declaration only. */ -const { toolsForConfig } = require('../../../services/nativeRuntimeService'); +const { toolsForConfig, TOOLS } = require('../../../services/nativeRuntimeService'); +const { hostedCaps } = require('../../../services/hostedRuntimeService'); const names = (tools) => tools.map((t) => t.function.name); +// Runs fn with these env vars set (undefined deletes), then restores them. +const withEnv = (vars, fn) => { + const saved = Object.fromEntries(Object.keys(vars).map((k) => [k, process.env[k]])); + try { + Object.entries(vars).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + return fn(); + } finally { + Object.entries(saved).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + } +}; +const NATIVE_HOSTING = { HOSTED_ENGINE: 'native', LITELLM_BASE_URL: 'http://litellm.test', LITELLM_MASTER_KEY: 'k' }; +const NO_HOSTING = { + HOSTED_ENGINE: undefined, + HOSTED_RUNTIME_URL: undefined, + HOSTED_RUNTIME_ADMIN_TOKEN: undefined, + LITELLM_BASE_URL: undefined, + LITELLM_MASTER_KEY: undefined, + LITELLM_API_KEY: undefined, +}; + describe('toolsForConfig', () => { test('a declared allowlist filters exactly', () => { const tools = toolsForConfig({ tools: ['commonly_post_message', 'commonly_propose_action'] }); @@ -29,13 +56,43 @@ describe('toolsForConfig', () => { test('the scout manifest grants propose_action', () => { // eslint-disable-next-line global-require const { scoutApp } = require('../../../config/native-agents/scout'); - const tools = toolsForConfig({ tools: [...scoutApp.tools] }); + // On an instance that can host, so the hosted offer is offered too. + const tools = withEnv(NATIVE_HOSTING, () => toolsForConfig({ tools: [...scoutApp.tools] })); expect(names(tools)).toContain('commonly_propose_action'); // The manifest and the runtime's schema must agree — a declared tool with // no schema would silently vanish here. expect(tools.length).toBe(scoutApp.tools.length); }); + test('the hosted offer is opt-in, and offered only where the instance can host', () => { + const declared = { tools: ['commonly_offer_hosted_agent'] }; + withEnv(NO_HOSTING, () => { + expect(names(toolsForConfig(declared))).not.toContain('commonly_offer_hosted_agent'); + }); + withEnv(NATIVE_HOSTING, () => { + expect(names(toolsForConfig(declared))).toContain('commonly_offer_hosted_agent'); + // Never on the default surface, even where hosting works. + expect(names(toolsForConfig({}))).not.toContain('commonly_offer_hosted_agent'); + expect(names(toolsForConfig({ tools: ['commonly_post_message'] }))).not.toContain('commonly_offer_hosted_agent'); + }); + // The Durable Object engine counts when its worker is configured. + withEnv({ ...NO_HOSTING, HOSTED_RUNTIME_URL: 'https://worker.test', HOSTED_RUNTIME_ADMIN_TOKEN: 't' }, () => { + expect(names(toolsForConfig(declared))).toContain('commonly_offer_hosted_agent'); + }); + }); + + // Scout told strangers "200 turns a day" after the free line became 30 + // (#2237): the number lived in a copy. The offer's description is built from + // the cap source, so the model is told the line the meter enforces. + test('the hosted offer states the limits the meter enforces', () => { + const offer = TOOLS.find((t) => t.function.name === 'commonly_offer_hosted_agent'); + const free = hostedCaps(); + const pro = hostedCaps({ pro: true }); + expect(offer.function.description).toContain(`${free.turnsPerDay} turns a day (${pro.turnsPerDay} on Pro)`); + expect(offer.function.description).toContain(`${free.agentsPerUser} hosted agent${free.agentsPerUser === 1 ? '' : 's'} per account`); + expect(offer.function.description).toMatch(/no invitation code/); + }); + test('agent_status is declaration-gated like every other tool', () => { // Scout declares it; a legacy config or a minimal allowlist does not // receive it implicitly. diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index a5a0de97c2..687f11b70d 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -69,8 +69,11 @@ export const scoutApp = { + 'your own" button (or "Set it up" on the "Your own agent" card) opens ' + 'the "Bring your own agent" page (/v2/agents/byo). It offers:\n' + ' * "Run it here": Commonly hosts the agent. Open to every signed-in user ' - + 'during beta, one hosted agent per account and 200 turns a day. Nothing to ' - + 'install; it is live in about ten seconds.\n' + + 'during beta, with no invitation code. Nothing to install; it is live a few ' + + 'seconds after the click. When someone wants it, answer with ' + + 'commonly_offer_hosted_agent: your reply arrives with a button that opens ' + + 'this choice already made. Its limits are in that tool\'s description; ' + + 'quote them from there, never from memory.\n' + ' * "Bring your own runtime": their own Claude Code or Codex, on their own ' + 'machine. The page issues a token once and shows these commands, which ' + 'are the whole setup:\n' @@ -94,7 +97,7 @@ export const scoutApp = { + 'terminal.\n' + '- The commands above are verified on macOS. Windows is not verified yet ' + '(the export lines are bash, and the issue is open): on Windows say so ' - + 'plainly and offer "Run it here" instead.\n' + + 'plainly and offer "Run it here" instead, with commonly_offer_hosted_agent.\n' + '- The listener runs Claude Code or Codex. Do not claim it runs any other ' + 'tool.\n' + '- Developers building their own agent: Commonly agents use a small HTTP ' @@ -154,6 +157,8 @@ export const scoutApp = { + 'once, without narrating it.\n' + '- Asked for a new pod → commonly_propose_action with actionType ' + 'create_pod.\n' + + '- They want Commonly to run an agent for them, or would rather not ' + + 'install anything → commonly_offer_hosted_agent.\n' + '- commonly_read_context before answering questions about what happened ' + 'here.', 'Never claim the user\'s own agent is connected or working — the ' @@ -173,6 +178,8 @@ export const scoutApp = { // via approval card only. 'commonly_propose_action', 'commonly_agent_status', + // Answers "can Commonly host it?" with a button to Run it here. + 'commonly_offer_hosted_agent', ], iconUrl: '', categories: ['onboarding', 'utility'], diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index bf3b9b23c8..4988ba4e13 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -22,7 +22,7 @@ const { AgentInstallation } = require('../models/AgentRegistry'); const User = require('../models/User'); const AgentIdentityService = require('../services/agentIdentityService'); const hostedRuntime = require('../services/hostedRuntimeService'); -const { isNativeRuntimeAvailable } = require('../services/nativeRuntimeService'); +const { hostedEngineAvailable, isNativeRuntimeAvailable } = require('../services/nativeRuntimeService'); const { HOSTED_ENGINE_NATIVE, engineForNewHostedInstall, @@ -145,11 +145,10 @@ const resolveOwnedHostedInstallation = async (req: any, source: Record ( - engine === HOSTED_ENGINE_NATIVE ? isNativeRuntimeAvailable() : hostedRuntime.isConfigured() -); +const engineAvailable = (engine: string): boolean => hostedEngineAvailable(engine); /** * Refuses before any database read when no engine can run on this instance. diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index 0f56649bdb..e9e5c836b9 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -9,7 +9,8 @@ * makes an unmetered public runtime safe to expose: * * HOSTED_AGENTS_PER_USER active hosted installations per owner (default 1) - * HOSTED_TURNS_PER_DAY acked events per hosted agent per UTC day (200) + * HOSTED_TURNS_PER_DAY acked events per hosted agent per UTC day (30; + * HOSTED_TURNS_PER_DAY_PRO, 200, on Pro) * * The daily cap is a RATE LIMIT ON REQUESTS, not a spend meter: it counts * acked AgentEvents by ack time (`deliveredAt`, which ack() stamps). One diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index eff7beb1b0..d2c63c7845 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -36,8 +36,12 @@ import { podIsBridged } from './outsiderGrantPodEligibility'; import { isSandboxConfigured, TurnSandbox } from './turnSandboxService'; import { isSearchConfigured, searchWeb } from './webSearchService'; import { untrustedConnectorMarker } from './brokerResultMarker'; -import { hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative } from './hostedEngine'; -import { hostedCapsForOwner, ownerIsPro } from './hostedRuntimeService'; +import { + engineForNewHostedInstall, HOSTED_ENGINE_NATIVE, hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative, +} from './hostedEngine'; +import { + hostedCaps, hostedCapsForOwner, isConfigured as hostedWorkerConfigured, listHostedInstallationsForUser, ownerIsPro, +} from './hostedRuntimeService'; // --- public surface -------------------------------------------------------- @@ -120,6 +124,25 @@ export function isNativeRuntimeAvailable(): boolean { return resolveLiteLLM() !== null; } +/** + * Whether a hosted agent on `engine` can run on this instance. The hosted + * routes and Scout's hosted offer both ask, so the offer never shows a button + * the page would refuse. + */ +export function hostedEngineAvailable(engine: string = engineForNewHostedInstall()): boolean { + return engine === HOSTED_ENGINE_NATIVE ? isNativeRuntimeAvailable() : hostedWorkerConfigured(); +} + +// What the hosted offer's tool description tells the model. Read from the cap +// source when this module loads, so no prompt holds its own copy of a limit +// (Scout quoted 200 turns a day after the free line became 30, #2237). +const HOSTED_FREE_CAPS = hostedCaps(); +const HOSTED_PRO_CAPS = hostedCaps({ pro: true }); +export const HOSTED_LIMITS_LINE = `${HOSTED_FREE_CAPS.agentsPerUser} hosted ` + + `agent${HOSTED_FREE_CAPS.agentsPerUser === 1 ? '' : 's'} per account and ` + + `${HOSTED_FREE_CAPS.turnsPerDay} turns a day (${HOSTED_PRO_CAPS.turnsPerDay} on Pro)`; +export const HOSTED_OFFER_LABEL = 'Run it here'; + // --- tool schema (OpenAI function-calling format) ------------------------- // // SOURCE OF TRUTH for the `internal` runtime's tool names. `CommonlyTool` @@ -261,6 +284,32 @@ export const TOOLS = [ parameters: { type: 'object', properties: {} }, }, }, + { + type: 'function', + function: { + name: 'commonly_offer_hosted_agent', + description: + 'Answer someone who wants Commonly to run an agent for them: nothing to install, no ' + + 'machine of their own, or on Windows. Posts your content to the pod with a ' + + `"${HOSTED_OFFER_LABEL}" button that opens Bring your own with hosting already chosen; ` + + 'one more click there and their agent is live in a few seconds. Open to every ' + + 'signed-in account during beta, no invitation code needed. The current limits are ' + + `${HOSTED_LIMITS_LINE}; quote these, never a number from memory. The content is your ` + + 'whole reply: do not post another message about it. If they already have a hosted ' + + 'agent, or this instance cannot host, nothing is posted and the result says why; ' + + 'tell them in your own words.', + parameters: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'Your reply, in the person\'s language. The button follows it.', + }, + }, + required: ['content'], + }, + }, + }, { type: 'function', function: { @@ -304,12 +353,14 @@ export const TOOLS = [ ] as const; // Tools an install gets only by declaring them: proposing an approval, -// running code (TASK-266), which also needs a sandbox key on the instance, and -// web search (TASK-267a), which needs a search key. +// offering hosting (Scout's), running code (TASK-266), which also needs a +// sandbox key on the instance, and web search (TASK-267a), which needs a +// search key. const OPT_IN_TOOLS: ReadonlySet = new Set([ - 'commonly_propose_action', 'commonly_run_code', 'commonly_web_search', + 'commonly_propose_action', 'commonly_offer_hosted_agent', 'commonly_run_code', 'commonly_web_search', ]); const toolAvailable = (name: string): boolean => { + if (name === 'commonly_offer_hosted_agent') return hostedEngineAvailable(); if (name === 'commonly_run_code') return isSandboxConfigured(); if (name === 'commonly_web_search') return isSearchConfigured(); return true; @@ -471,6 +522,50 @@ async function dispatchTool( return { content: { ok: true, posted: true, result } }; } + case 'commonly_offer_hosted_agent': { + // The answer and its button in one message. The button is composed + // here, never by the model: an in-app path to Bring your own aimed at + // this pod with hosting chosen (V2AgentBYO reads ?mode=hosted). + const content = String(args.content || '').trim(); + if (!content) { + return { content: { ok: false, error: 'content is required' }, error: 'missing_content' }; + } + // The person Scout serves is whoever installed it. If they already + // hold the beta's hosted agents, a button would only lead to the + // install route's 403, so say who holds the slot instead. + const { agentsPerUser } = hostedCaps(); + const held = ctx.installation?.installedBy + ? await listHostedInstallationsForUser(ctx.installation.installedBy) + : []; + if (held.length >= agentsPerUser) { + return { + content: { + ok: false, + error: `They already have ${held.length} hosted agent${held.length === 1 ? '' : 's'}, ` + + `the most the beta allows (${agentsPerUser}). Mention it to use it, or remove it on ` + + 'the Agents page to host a different one.', + hostedAgents: held.map((h) => h.agentName), + }, + error: 'hosted_cap_reached', + }; + } + const href = `/v2/agents/byo?pod=${encodeURIComponent(String(ctx.podId))}&mode=hosted`; + const AgentMessageService = require('./agentMessageService'); + const result = await AgentMessageService.postMessage({ + agentName: ctx.agentName, + instanceId: ctx.instanceId, + podId: ctx.podId, + content, + messageType: 'text', + displayName: ctx.displayName, + installationConfig: ctx.installationConfig, + metadata: { source: 'native-runtime' }, + payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: HOSTED_OFFER_LABEL, href }] }, + noOutboundRelay: Boolean(ctx.webTainted), + }); + return { content: { ok: true, posted: true, result } }; + } + case 'commonly_create_task': { const title = String(args.title || '').trim(); if (!title) { @@ -1441,6 +1536,14 @@ export async function runAgent( if (toolName === 'commonly_propose_action' && !result.error) { postedViaTool = true; } + // The hosted offer posts the model's own reply with its button, so + // the same holds: the fallback must not post the narration twice. + if (toolName === 'commonly_offer_hosted_agent' && !result.error) { + postedViaTool = true; + finalMessage = typeof (parsedArgs as any)?.content === 'string' + ? String((parsedArgs as any).content) + : undefined; + } messages.push({ role: 'tool', tool_call_id: tc.id, diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index 10f135addb..7f6edc5078 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -159,6 +159,65 @@ describe('V2AgentBYO — Run it here', () => { window.history.pushState({}, '', '/'); }); + // Scout's hosted offer links here with ?mode=hosted: the person asked + // Commonly to host their agent, so the page opens on "Run it here" and its + // act is the next click (QA 2026-10-16: the hand-off was text only). + const renderAt = (url) => { + window.history.pushState({}, '', url); + return render( + + + + + , + ); + }; + + test('a hosted offer link (?mode=hosted) opens on Run it here, one click from live', async () => { + mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); + axios.post.mockResolvedValue({ data: { ok: true } }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true')); + expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByRole('button', { name: /^run it here$/i })); + + await screen.findByTestId('byo-hosted-result'); + const installCall = axios.post.mock.calls.find(([url]) => url === '/api/registry/install'); + expect(installCall[1]).toMatchObject({ agentName: 'sam-agent', podId: 'p1', config: { runtime: { runtimeType: 'hosted' } } }); + window.history.pushState({}, '', '/'); + }); + + test('a hosted offer link offers no act until the instance says it can host', async () => { + let answerAvailability; + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/hosted/availability')) { + return new Promise((resolve) => { answerAvailability = resolve; }); + } + if (url === '/api/pods') return Promise.resolve({ data: pods }); + return Promise.resolve({ data: {} }); + }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + const podPicker = await screen.findByRole('combobox', { name: /install into pod/i }); + await waitFor(() => expect(podPicker).toHaveValue('p1')); + expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument(); + + await act(async () => { answerAvailability({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } }); }); + expect(await screen.findByRole('button', { name: /^run it here$/i })).toBeEnabled(); + window.history.pushState({}, '', '/'); + }); + + test('a hosted offer link on an instance that cannot host is the plain BYO page', async () => { + mockGet({ configured: false }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + expect(await screen.findByRole('button', { name: /install \+ generate token/i })).toBeEnabled(); + expect(screen.queryByTestId('byo-mode-hosted')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument(); + window.history.pushState({}, '', '/'); + }); + test('a persona hire sends the persona\'s card sentence as the seat description (#1649)', async () => { mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); axios.post.mockResolvedValue({ data: { ok: true } }); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 94112e39ec..5654754eab 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -98,6 +98,9 @@ const V2AgentBYO: React.FC = () => { pod: (params.get('pod') || '').replace(/[^a-f0-9]/gi, ''), name: sanitizeAgentName(params.get('name') || ''), persona: sanitizeAgentName(params.get('persona') || ''), + // ?mode=hosted comes from Scout's "Run it here" button: the person + // already asked Commonly to host their agent. + hosted: params.get('mode') === 'hosted', }; })(); // Sam (2026-09-01): persona deep links carry context into this otherwise @@ -144,8 +147,13 @@ const V2AgentBYO: React.FC = () => { // hosting. A ?name= deep link comes from Scout's connect_local_agent card: // the person asked to connect THEIR OWN agent (e.g. Claude Code), so it // starts on "Bring your own runtime" (Growth's walk, 2026-10-14: one click - // gave a hosted agent named after their Claude Code). - const [chosenMode, setMode] = useState<'hosted' | 'byo' | 'machine' | null>(prefill.name ? 'byo' : null); + // gave a hosted agent named after their Claude Code). A ?mode=hosted link + // comes from Scout's hosted offer, where the person asked to be hosted, so + // it starts on "Run it here". + const [chosenMode, setMode] = useState<'hosted' | 'byo' | 'machine' | null>(() => { + if (prefill.hosted) return 'hosted'; + return prefill.name ? 'byo' : null; + }); const [hosted, setHosted] = useState<{ agentName: string; podId: string } | null>(null); const [hostedState, setHostedState] = useState<'starting' | 'running' | 'slow'>('starting'); // ADR-026 Phase 2: "on my computer" — install + placement request; the @@ -165,7 +173,11 @@ const V2AgentBYO: React.FC = () => { // are in, a hosting reply that never arrived well-formed reads as "cannot // host", and the machines list as empty. const choosingMode = !choicesKnown || Boolean(hosting?.configured) || machines.length > 0; - const mode = chosenMode ?? (choosingMode ? null : 'byo'); + // "Run it here" counts only once the instance says it can host: a hosted + // link must not arm a hosted install before, or without, that answer. + const mode = chosenMode === 'hosted' && !hosting?.configured + ? (choosingMode ? null : 'byo') + : chosenMode ?? (choosingMode ? null : 'byo'); // One lookup, two readers (TASK-163): the preview note and the placement // payload must name the SAME machine, so it is derived once here rather // than restated at each call site.