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 @@ -182,8 +182,7 @@ describe('Scout\'s welcome', () => {
const { SCOUT_WELCOME } = require('../../../config/native-agents/scout');

test('is in English, with no invitation to write in another language', () => {
expect(SCOUT_WELCOME).toBe('I\'m Scout. This pod is yours, and I work in it with you. '
+ 'Start with one of these, or tell me what you\'re working on.');
expect(SCOUT_WELCOME).toBe('I\'m Scout. Pick a start or tell me your goal.');
expect(SCOUT_WELCOME).not.toMatch(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uff00-\uffef]/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => {

const welcome = mockPostMessage.mock.calls[0][0];
expect(welcome.content).toBe(SCOUT_WELCOME);
expect(welcome.content.split(/\s+/)).toHaveLength(10);
expect(welcome.payload).toEqual({
kind: 'starts',
starts: [
Expand All @@ -131,7 +132,7 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => {

// QA walk 3 (2026-10-16): where the instance can host, the welcome leads with
// hosting, nothing to install. It sends no message: on the person's own
// welcome it runs in place (TASK-290), with the beta limits under it, and its
// welcome it runs in place (TASK-290), and its
// link opens Bring your own with hosting chosen, in this workspace, for
// anyone it does not run for.
test('leads the starts with Run an agent here, aimed at this workspace, where the instance can host', async () => {
Expand All @@ -144,15 +145,13 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => {
const { SCOUT_STARTS, SCOUT_HOSTED_START } = require('../../../config/native-agents/scout');
await authController.finishWorkspaceOnboarding({ _id: 'pod-123' }, 'User-1');

// eslint-disable-next-line global-require
const { hostedCaps } = require('../../../services/hostedRuntimeService');
const { starts } = mockPostMessage.mock.calls[0][0].payload;
expect(starts[0]).toEqual({
id: 'run-hosted',
label: SCOUT_HOSTED_START.label,
href: '/v2/agents/byo?pod=pod-123&mode=hosted',
action: 'run-hosted',
caps: hostedCaps(),
caps: { agentsPerUser: 1, turnsPerDay: 30 },
});
expect(starts.slice(1)).toEqual(SCOUT_STARTS.map(({ id, label }) => ({ id, label })));
} finally {
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/unit/services/welcomeHostedStart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const welcomePayload = () => ({
starts: [
{
id: 'run-hosted',
label: 'Run an agent here, nothing to install',
label: 'Run an agent here',
href: HREF,
action: 'run-hosted',
caps: { agentsPerUser: 1, turnsPerDay: 30 },
Expand Down
5 changes: 2 additions & 3 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,10 @@ export const SCOUT_STARTS = [
* in place (TASK-290, services/welcomeHostedStart.ts); anywhere else its href,
* added by the signup path, opens Bring your own with hosting chosen.
*/
export const SCOUT_HOSTED_START = { id: 'run-hosted', label: 'Run an agent here, nothing to install' } as const;
export const SCOUT_HOSTED_START = { id: 'run-hosted', label: 'Run an agent here' } as const;

/** Scout's first message in a new workspace; the starts render under it. */
export const SCOUT_WELCOME = 'I\'m Scout. This pod is yours, and I work in it with you. '
+ 'Start with one of these, or tell me what you\'re working on.';
export const SCOUT_WELCOME = 'I\'m Scout. Pick a start or tell me your goal.';

export const scoutApp = {
agentName: 'scout',
Expand Down
6 changes: 3 additions & 3 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,9 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => {
const AgentMessageService = require('../services/agentMessageService');
// Lead with hosting where this instance can host (QA walk 3, 10-16):
// nothing to install. One click starts a hosted agent right here
// (TASK-290, services/welcomeHostedStart.ts), with the beta limits
// under the button. The href stays for anyone the thread will not
// run it for: it opens Bring your own with hosting chosen.
// (TASK-290, services/welcomeHostedStart.ts). The href stays for
// anyone the thread will not run it for: it opens Bring your own
// with hosting chosen.
// eslint-disable-next-line global-require
const { hostedEngineAvailable } = require('../services/nativeRuntimeService');
// eslint-disable-next-line global-require
Expand Down
32 changes: 23 additions & 9 deletions frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const welcome = {
id: 'welcome',
pod_id: 'pod-1',
user_id: 'scout-1',
content: "I'm Scout. This pod is yours, and I work in it with you. Start with one of these, or tell me what you're working on.",
content: "I'm Scout. Pick a start or tell me your goal.",
message_type: 'text',
created_at: at(0),
user: { username: 'scout', isBot: true },
Expand Down Expand Up @@ -270,7 +270,7 @@ describe("V2Thread — a new pod's next steps", () => {
// Walk 4 (2026-10-16): two first-minute frictions on the hosted path.
describe('Scout starts and the composer after a hosted agent goes live', () => {
const axios = jest.requireMock('axios').default;
const HOSTED = 'Run an agent here, nothing to install';
const HOSTED = 'Run an agent here';
const welcomeWithHosted = {
...welcome,
payload: {
Expand Down Expand Up @@ -355,7 +355,7 @@ describe('Scout starts and the composer after a hosted agent goes live', () => {
// gets the same agent, and anything refused falls back to the link.
describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-290)', () => {
const axios = jest.requireMock('axios').default;
const HOSTED = 'Run an agent here, nothing to install';
const HOSTED = 'Run an agent here';
const HREF = '/v2/agents/byo?pod=pod-1&mode=hosted';
const welcomeRunsHere = {
...welcome,
Expand Down Expand Up @@ -385,10 +385,12 @@ describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-29
beforeEach(() => { axios.post.mockClear(); });
afterEach(() => { axios.post.mockImplementation(() => Promise.resolve({ data: {} })); });

test('the beta limits sit under the button, and describe it', () => {
test('the welcome skips the beta limits even for older messages carrying caps', () => {
renderThread([welcomeRunsHere]);
expect(screen.getByRole('button', { name: HOSTED }))
.toHaveAccessibleDescription('Beta: 1 hosted agent per account, 30 turns a day.');
const button = screen.getByRole('button', { name: HOSTED });
expect(button).not.toHaveAccessibleDescription();
expect(button).not.toHaveAttribute('aria-describedby');
expect(screen.queryByText('Beta: 1 hosted agent per account, 30 turns a day.')).not.toBeInTheDocument();
});

test('one click installs and starts the agent in this pod, sends nothing, and the start goes away', async () => {
Expand All @@ -397,10 +399,22 @@ describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-29
'/api/registry/install': [{ success: true }],
'/api/hosted/provision': [{ ready: true }],
});
const sendSpy = renderThread([welcomeRunsHere]);
fireEvent.click(screen.getByRole('button', { name: HOSTED }));
// A second payload with the same caps must not inherit this welcome's live note.
const otherStart = {
...welcomeRunsHere,
id: 'other-start',
created_at: at(1),
user_id: 'u2',
user: { username: 'lee', isBot: false },
};
const sendSpy = renderThread([welcomeRunsHere, otherStart]);
fireEvent.click(screen.getAllByRole('button', { name: HOSTED })[0]);
expect(await screen.findByRole('button', { name: 'Starting…' })).toBeDisabled();
await waitFor(() => expect(screen.queryByRole('button', { name: /Run an agent here|Starting/ })).not.toBeInTheDocument());
expect(screen.getByText('maya-agent is listening. Beta: 1 hosted agent per account, 30 turns a day.'))
.toHaveAttribute('role', 'status');
expect(screen.getAllByText('maya-agent is listening. Beta: 1 hosted agent per account, 30 turns a day.'))
.toHaveLength(1);

expect(calls('/api/hosted/welcome-start').map(([, body]) => body)).toEqual([{ messageId: 'welcome' }]);
expect(calls('/api/registry/install')).toHaveLength(1);
Expand All @@ -410,7 +424,7 @@ describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-29
expect(calls('/api/hosted/provision').map(([, body]) => body)).toEqual([{ agentName: 'maya-agent' }]);
expect(sendSpy).not.toHaveBeenCalled();
expect(screen.getByTestId('location')).toHaveTextContent(/^\/$/);
expect(screen.getByRole('button', { name: CONNECT })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: CONNECT })).toHaveLength(2);
});

test('a double click starts one agent', async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/__tests__/useWelcomeHostedStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ test('a second run while the first is in flight sends nothing', async () => {
'/api/hosted/welcome-start', '/api/registry/install', '/api/hosted/provision',
]);
expect(onLive).toHaveBeenCalledTimes(1);
expect(result.current.state).toEqual({ messageId: '41', status: 'live' });
expect(result.current.state).toEqual({ messageId: '41', status: 'live', agentName: 'maya-agent' });
});
17 changes: 10 additions & 7 deletions frontend/src/v2/components/V2ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
{emptyState}
{threadView.map((item, index, view) => {
const renderMessage = (message: V2Message, previous: ThreadViewItem | undefined) => {
const liveHostedCaps = hostedStart?.status === 'live' && hostedStart.messageId === String(message.id)
? message.payload?.starts?.find((start) => start.action === WELCOME_HOSTED_ACTION)?.caps
: undefined;
const settledRuling = settledDecisionByMessageId.get(String(message.id));
const renderedMessage = settledRuling ? rulingMessage(message, settledRuling) : message;
const previousMessage = previous && previous.kind === 'message'
Expand Down Expand Up @@ -293,24 +296,17 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
const hostedHere = start.action === WELCOME_HOSTED_ACTION
&& hostedStart?.messageId === String(message.id) ? hostedStart : null;
const starting = hostedHere?.status === 'starting';
const noteId = start.caps ? `v2-start-note-${message.id}-${start.id}` : undefined;
return (
<React.Fragment key={start.id}>
<button
type="button"
className="v2-chat__start"
disabled={startsDisabled || starting}
aria-busy={starting || undefined}
aria-describedby={noteId}
onClick={() => onStart(start, String(message.id))}
>
{starting ? t('agentByo.actions.starting') : start.label}
</button>
{start.caps && (
<p className="v2-chat__start-note" id={noteId}>
{t('agentByo.compact.limits', { agents: start.caps.agentsPerUser, turns: start.caps.turnsPerDay })}
</p>
)}
{hostedHere?.status === 'failed' && (
<p className="v2-chat__start-note v2-chat__start-note--error" role="alert">{hostedHere.error}</p>
)}
Expand All @@ -319,6 +315,13 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
})}
</div>
)}
{liveHostedCaps && (
<p className="v2-chat__start-note" role="status">
{t('agentByo.hosted.running', { name: hostedStart?.status === 'live' ? hostedStart.agentName : '' })}
{' '}
{t('agentByo.compact.limits', { agents: liveHostedCaps.agentsPerUser, turns: liveHostedCaps.turnsPerDay })}
</p>
)}
{agentDeliveryHint?.messageId === message.id && (
<div className="v2-chat__delivery-hint" role="status">
<Trans
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/hooks/useV2PodDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface V2Start {
label: string;
href?: string;
action?: string;
// The welcome's hosted start carries the beta limits shown under it.
// The first read omits caps; the thread shows them after the hosted start is live.
caps?: { agentsPerUser: number; turnsPerDay: number };
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/v2/hooks/useWelcomeHostedStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface WelcomeStartPlan {

export type WelcomeHostedStartState =
| { messageId: string; status: 'starting' }
| { messageId: string; status: 'live' }
| { messageId: string; status: 'live'; agentName: string }
| { messageId: string; status: 'failed'; error: string }
| null;

Expand Down Expand Up @@ -77,7 +77,7 @@ export function useWelcomeHostedStart(
agentName: agent.agentName,
...(agent.instanceId ? { instanceId: agent.instanceId } : {}),
});
setState({ messageId, status: 'live' });
setState({ messageId, status: 'live', agentName: agent.agentName });
onLive();
} catch (err) {
const data = (err as ApiError).response?.data;
Expand Down
Loading