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
59 changes: 59 additions & 0 deletions backend/__tests__/service/onboardingByoOpenedOs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* byoOpenedOs round-trips through the real User schema: the enum admits the
* four values markByoOpened writes, and the mark's write-once filter keeps the
* first open's OS. The unit test (unit/services/onboardingMarks.test.js) pins
* the update payload; this pins that the schema stores it.
*/
const User = require('../../models/User');
const { markByoOpened, resetOnboardingMarksForTest } = require('../../services/onboardingMarks');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');

const makeUser = (name) => User.create({
username: name, email: `${name}@example.test`, password: 'Opened-os-123!',
});

const settle = async (id, predicate) => {
for (let i = 0; i < 50; i += 1) {
// eslint-disable-next-line no-await-in-loop
const row = await User.findById(id).lean();
if (predicate(row)) return row;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 20));
}
return User.findById(id).lean();
};

beforeAll(setupMongoDb);
afterAll(closeMongoDb);
beforeEach(async () => {
await clearMongoDb();
resetOnboardingMarksForTest();
});

test.each(['mac', 'linux', 'windows', 'other'])('stores os=%s with the first-open time', async (os) => {
const user = await makeUser(`opened-${os}`);
markByoOpened(String(user._id), os);
const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt);
expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date);
expect(row.onboarding.byoOpenedOs).toBe(os);
});

test('an unknown value records the visit without an OS', async () => {
const user = await makeUser('opened-ios');
markByoOpened(String(user._id), 'ios');
const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt);
expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date);
expect(row.onboarding).not.toHaveProperty('byoOpenedOs');
});

test('a later open from another OS does not overwrite the first', async () => {
const user = await makeUser('opened-twice');
markByoOpened(String(user._id), 'windows');
const first = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt);
resetOnboardingMarksForTest(); // as after a restart
markByoOpened(String(user._id), 'mac');
await new Promise((r) => setTimeout(r, 200));
const row = await User.findById(user._id).lean();
expect(row.onboarding.byoOpenedOs).toBe('windows');
expect(row.onboarding.byoOpenedAt).toEqual(first.onboarding.byoOpenedAt);
});
7 changes: 6 additions & 1 deletion backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,12 @@ describe('/api/hosted', () => {
const res = await request(app).get('/api/hosted/availability');
expect(res.status).toBe(200);
expect(mockMarkByoOpened).toHaveBeenCalledTimes(1);
expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1');
expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', undefined);
});

it('passes the OS the page detected to the first-visit mark', async () => {
await request(app).get('/api/hosted/availability?os=linux');
expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', 'linux');
});

it('checks new-pod hosted availability without marking Bring your own opened', async () => {
Expand Down
22 changes: 22 additions & 0 deletions backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,28 @@ describe('markByoOpened', () => {
expect(update.$set['onboarding.byoOpenedAt']).toBeInstanceOf(Date);
});

test('records the OS the page detected in the same write-once update', () => {
markByoOpened('u1', 'windows');
const [filter, update] = mockUpdateOne.mock.calls[0];
expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } });
expect(Object.keys(update.$set)).toEqual(['onboarding.byoOpenedAt', 'onboarding.byoOpenedOs']);
expect(update.$set['onboarding.byoOpenedOs']).toBe('windows');
});

test.each(['mac', 'linux', 'windows', 'other'])('keeps os=%s', (os) => {
markByoOpened('u1', os);
expect(mockUpdateOne.mock.calls[0][1].$set['onboarding.byoOpenedOs']).toBe(os);
});

test.each([['ios'], ['Mac'], [''], [['mac']], [{ os: 'mac' }], [42]])(
'any other os value (%p) records the visit without an OS',
(os) => {
markByoOpened('u1', os);
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
expect(Object.keys(mockUpdateOne.mock.calls[0][1].$set)).toEqual(['onboarding.byoOpenedAt']);
},
);

test('no person, no write', () => {
markByoOpened('');
markByoOpened(undefined);
Expand Down
54 changes: 53 additions & 1 deletion backend/__tests__/unit/services/stalledConnectService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,59 @@ describe('stalledConnectService.scan', () => {
'copied the listener command a few minutes after you created the seat',
);
expect(posted.content).toContain('no connection arrived');
expect(posted.content).toContain('one thing to check for your OS');
// Nothing here knows the OS, so the copy doesn't claim to.
expect(posted.content).toContain('here is the one thing to check: run `commonly agent run ai1-agent`');
expect(posted.content).not.toContain('for your OS');
});

const copiedListenerOn = (byoOpenedOs, byoCommandType = 'cli') => mockUserFindById.mockReturnValue({
select: () => ({
lean: () => Promise.resolve({
username: 'alice',
onboarding: {
byoCommandCopiedAt: new Date(OLD_TOKEN.getTime() + 8 * MINUTE),
byoCommandType,
...(byoOpenedOs ? { byoOpenedOs } : {}),
},
}),
}),
});

it.each([['linux', 'Linux'], ['windows', 'Windows']])(
'from a %s browser, says the listener answers @mentions only on a Mac, if it ran there',
async (os, label) => {
setup();
copiedListenerOn(os);
await scan({ now: NOW });
const { content } = mockPost.mock.calls[0][0];
// Conditional: the page shows this command only on its Mac tab, so the
// browser's OS is not proof of where it ran.
expect(content).toContain(`If you ran it on ${label}, that's likely why: the listener answers @mentions only from a Mac for now.`);
// The server cannot tell Claude Code from Codex, so no adapter-specific cause.
expect(content).not.toContain('sandbox');
expect(content).toContain('Run `commonly agent run ai1-agent` on a Mac');
expect(content).toContain('or choose “Run it here” on the Bring your own page for a hosted agent');
expect(content).toContain("I'll post here the moment I connect");
expect(content).not.toContain('one thing to check');
},
);

it.each(['mac', 'other', undefined])('with os=%p keeps the neutral check', async (os) => {
setup();
copiedListenerOn(os);
await scan({ now: NOW });
const { content } = mockPost.mock.calls[0][0];
expect(content).toContain('here is the one thing to check: run');
expect(content).not.toContain("can't answer @mentions");
});

it('an MCP copy on Linux is not told about the listener', async () => {
setup();
copiedListenerOn('linux', 'mcp');
await scan({ now: NOW });
const { content } = mockPost.mock.calls[0][0];
expect(content).not.toContain("listener can't answer");
expect(content).toContain('MCP-only clients can call Commonly');
});

it('explains that an MCP-only client cannot answer mentions', async () => {
Expand Down
3 changes: 3 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface IUser extends Document {
firstStartId?: string;
firstStartAt?: Date;
byoOpenedAt?: Date;
/** OS the page detected on that first open, written with byoOpenedAt. */
byoOpenedOs?: 'mac' | 'linux' | 'windows' | 'other';
/** First BYO setup command copied in the browser, never overwritten. */
byoCommandCopiedAt?: Date;
/** Fixed kind only; command contents and tokens are never stored. */
Expand Down Expand Up @@ -465,6 +467,7 @@ const userSchema = new Schema<IUser>({
firstStartId: { type: String },
firstStartAt: { type: Date },
byoOpenedAt: { type: Date },
byoOpenedOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] },
byoCommandCopiedAt: { type: Date },
byoCommandType: { type: String, enum: ['mcp', 'cli'] },
byoFirstConnectionAttemptAt: { type: Date },
Expand Down
5 changes: 3 additions & 2 deletions backend/routes/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,11 @@ const relayWorkerError = (res: express.Response, error: any) => {
*
* Only the Bring your own page calls this, on load, so it also records the
* person's first visit to that page (write-once, fire-and-forget;
* services/onboardingMarks.ts).
* services/onboardingMarks.ts). `?os=` is the OS the page detected; the mark
* keeps only mac, linux, windows or other and records no OS for anything else.
*/
router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => {
markByoOpened(getUserId(req));
markByoOpened(getUserId(req), req.query?.os);
const engine = engineForNewHostedInstall();
// The caller's own plan line (TASK-268), so the page states the limit they get.
const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req));
Expand Down
25 changes: 23 additions & 2 deletions backend/services/onboardingMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
* nothing else calls). It tells "never looked at setup" apart from "looked and
* left".
*
* byoOpenedOs is written with it, in the same write-once update: the OS the
* page detected from the browser, one of BYO_OPENED_OS. The listener runs only
* on Mac (listenerRunsOn in V2AgentBYO.tsx is os === 'mac'), so this splits
* "left" by whether the listener path could have worked. An unknown or
* missing value records the visit without an OS.
*
* Three surface marks come from the browser through POST /api/stats/byo-step,
* as fixed step names: inviteCardShownAt and inviteCardDismissedAt for
* the "invite a teammate" card, and byoOfferShownAt the first time the thread
Expand Down Expand Up @@ -102,15 +108,30 @@ function recordFirstStart(id: string, startId: string): void {
}
}

export function markByoOpened(userId: unknown): void {
export const BYO_OPENED_OS = ['mac', 'linux', 'windows', 'other'] as const;
export type ByoOpenedOs = typeof BYO_OPENED_OS[number];

export const parseByoOpenedOs = (value: unknown): ByoOpenedOs | undefined => (
typeof value === 'string' && (BYO_OPENED_OS as readonly string[]).includes(value)
? value as ByoOpenedOs
: undefined
);

export function markByoOpened(userId: unknown, os?: unknown): void {
const id = String(userId || '');
if (!id) return;
if (byoOpenedMarked.has(id)) return;
byoOpenedMarked.add(id);
const detectedOs = parseByoOpenedOs(os);
try {
User.updateOne(
{ _id: id, isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } },
{ $set: { 'onboarding.byoOpenedAt': new Date() } },
{
$set: {
'onboarding.byoOpenedAt': new Date(),
...(detectedOs ? { 'onboarding.byoOpenedOs': detectedOs } : {}),
},
},
).catch(() => {
byoOpenedMarked.delete(id);
});
Expand Down
31 changes: 30 additions & 1 deletion backend/services/stalledConnectCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,26 @@ export type StalledConnectCopyEvidence = {
commandType?: 'mcp' | 'cli' | null;
/** Token issue time for the current stalled episode. */
tokenIssuedAt?: Date | string | null;
/** OS the Bring your own page detected on the first open (byoOpenedOs). */
openedOs?: 'mac' | 'linux' | 'windows' | 'other' | null;
};

/**
* The listener answers @mentions only on a Mac for now (V2AgentBYO's
* listenerRunsOn), for a different reason per adapter: Commonly's sandbox
* cannot run Claude Code on Linux or Windows, and Codex there is not yet
* tested (agentByo.listen.unavailableClaude / unavailableCodex). The server
* never learns which adapter the copied command named, so the copy names the
* listener, not the CLI, and states only what is true for both: Mac only.
*
* It hedges, unlike the rest of this file. The page shows the listener command
* only on its Mac tab, so a copy from a Linux or Windows browser means the
* person picked Mac there: they may have run it on a Mac, or on the machine
* they were browsing from. byoOpenedOs is the browser's OS, not where the
* command ran, so the sentence is conditional on it.
*/
const LISTENER_UNAVAILABLE_OS: Record<string, string> = { linux: 'Linux', windows: 'Windows' };

const copiedAtOffsetSeconds = (evidence: StalledConnectCopyEvidence): number | null => {
if (!evidence.commandCopiedAt || !evidence.tokenIssuedAt) return null;
const copiedAt = evidence.commandCopiedAt instanceof Date
Expand Down Expand Up @@ -82,9 +100,20 @@ export const composeStalledConnectNudge = ({
const copiedThing = evidence.commandType === 'mcp'
? 'MCP command'
: evidence.commandType === 'cli' ? 'listener command' : 'command';
const unavailableOs = evidence.commandType === 'cli' && evidence.openedOs
? LISTENER_UNAVAILABLE_OS[evidence.openedOs]
: undefined;
if (unavailableOs) {
return `@${installerName} — you copied the listener command `
+ `${copiedAtDescription(offsetSeconds)}; no connection arrived. `
+ `If you ran it on ${unavailableOs}, that's likely why: the listener answers @mentions `
+ `only from a Mac for now. `
+ `Run \`${fixCommand}\` on a Mac, or choose “Run it here” on the Bring your own page for a hosted agent. `
+ `I'll post here the moment I connect.`;
}
return `@${installerName} — you copied the ${copiedThing} `
+ `${copiedAtDescription(offsetSeconds)}; `
+ `no connection arrived — here is the one thing to check for your OS: `
+ `no connection arrived — here is the one thing to check: `
+ `run \`${fixCommand}\` on the machine where I should live.`
+ (evidence.commandType === 'mcp'
? ' MCP-only clients can call Commonly, but they do not answer @mentions.'
Expand Down
2 changes: 2 additions & 0 deletions backend/services/stalledConnectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ const nudgeOnce = async ({
onboarding?: {
byoCommandCopiedAt?: Date;
byoCommandType?: 'mcp' | 'cli';
byoOpenedOs?: 'mac' | 'linux' | 'windows' | 'other';
};
} | null;
if (!installer?.username) return; // Nobody to address; a nameless nudge is noise.
Expand Down Expand Up @@ -292,6 +293,7 @@ const nudgeOnce = async ({
evidence: {
commandCopiedAt: installer.onboarding?.byoCommandCopiedAt,
commandType: installer.onboarding?.byoCommandType,
openedOs: installer.onboarding?.byoOpenedOs,
tokenIssuedAt: issuedAt,
},
});
Expand Down
1 change: 1 addition & 0 deletions docs/runbooks/new-signup-stall.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ confirm, for example against `lastActive` or whether they sent a message
| `podOpenedAt` | the first authorized first-page fetch (no `before` cursor) of the messages of any pod they created; for a new signup that is normally My Workspace. It is set before the messages are read, so it records the request, not a render | their client asked for a pod's messages; for a new signup, very likely the welcome |
| `firstStartAt` + `firstStartId` | their first message in a pod they created matches a start's label, or the server resolves a click on their own "Run an agent here" | they acted, and which choice |
| `byoOpenedAt` | they first load Bring your own (`GET /api/hosted/availability`) | they looked at setup |
| `byoOpenedOs` | written with `byoOpenedAt`, from the `?os=` the page sends: `mac`, `linux`, `windows` or `other`. Unset for anyone who opened the page before it shipped, and for an unknown value | whether the listener path (Mac only) could have worked for them |

Read them together with whether the person sent a message in their workspace,
and when (timestamps and ids).
Expand Down
51 changes: 50 additions & 1 deletion frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe('V2AgentBYO — Run it here', () => {
);
await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument());
// let the availability effect settle, then assert it did not stomp the mode
await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url === '/api/hosted/availability')).toBe(true));
await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/hosted/availability'))).toBe(true));
await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'true'));
expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'false');
window.history.pushState({}, '', '/');
Expand Down Expand Up @@ -482,3 +482,52 @@ describe('V2AgentBYO — Run it here', () => {
expect(axios.post.mock.calls[0][1]).toMatchObject({ config: { runtime: { runtimeType: 'webhook' } } });
});
});

// The page's load request carries the OS it detected, for the server's
// first-open mark (User.onboarding.byoOpenedOs). Unlike the setup tabs, an
// OS it can't place is reported as 'other', never as a Mac.
describe('V2AgentBYO — detected OS on the availability request', () => {
const availabilityUrl = () => axios.get.mock.calls
.map(([url]) => url)
.find((url) => url.startsWith('/api/hosted/availability'));

const withPlatform = async (platform, userAgent, touchPoints = 0) => {
const uaData = Object.getOwnPropertyDescriptor(window.navigator, 'userAgentData');
const ua = jest.spyOn(window.navigator, 'userAgent', 'get');
if (userAgent) ua.mockReturnValue(userAgent);
const touch = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints');
Object.defineProperty(window.navigator, 'maxTouchPoints', { value: touchPoints, configurable: true });
Object.defineProperty(window.navigator, 'userAgentData', {
value: platform === undefined ? undefined : { platform }, configurable: true,
});
try {
mockGet({ configured: true });
renderPage();
await waitFor(() => expect(availabilityUrl()).toBeDefined());
return availabilityUrl();
} finally {
ua.mockRestore();
if (touch) Object.defineProperty(window.navigator, 'maxTouchPoints', touch);
else delete window.navigator.maxTouchPoints;
if (uaData) Object.defineProperty(window.navigator, 'userAgentData', uaData);
else delete window.navigator.userAgentData;
}
};

test.each([
['macOS', undefined, 'mac'],
['Windows', undefined, 'windows'],
['Linux', undefined, 'linux'],
['Android', undefined, 'other'],
[undefined, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', 'other'],
[undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 'mac'],
[undefined, 'Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3', 'other'],
])('platform %p / UA %p reports os=%s', async (platform, userAgent, os) => {
expect(await withPlatform(platform, userAgent)).toBe(`/api/hosted/availability?os=${os}`);
});

test('an iPad in desktop mode (Mac UA, touch) reports os=other', async () => {
expect(await withPlatform(undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 5))
.toBe('/api/hosted/availability?os=other');
});
});
Loading
Loading