Skip to content

Commit 4eeacbd

Browse files
committed
feat(app): share one message_sent schema across phone, web and desktop
message_sent now carries the properties every Happy client sends under the same names (MessageSentSharedProperties, mirrored by happy-desktop): - client: ios | android | web | desktop (Tauri reports desktop) - target: chief_of_staff | bot | session, from metadata.bot.systemKey - bot_system_key: the raw system bot key, never a bot name or id - task_depth: metadata.depth when a whole number from zero up - agent_os: mac | win | linux | other, from the session OS or its machine - model_provider_kind: the provider type as published, no invented `custom` - provider_account_hash: a per-user keyed hash of the provider account id session_agent is removed: for Happy Agent sessions it carried the raw provider account id. The hash key is derived from the account secret under its own analytics path, so the same account hashes the same for one user and cannot be joined across users. happy-wire and the session metadata schema accept bot.systemKey and a lenient optional depth, so a bad value never fails the metadata parse.
1 parent 67fe78d commit 4eeacbd

11 files changed

Lines changed: 312 additions & 33 deletions

File tree

‎packages/happy-app/sources/sync/encryption/encryption.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,21 @@ export class Encryption {
2222
// Derive anonymous ID
2323
const anonID = encodeHex((await deriveKey(masterSecret, 'Happy Coder', ['analytics', 'id']))).slice(0, 16).toLowerCase();
2424

25+
// Derive the key that hashes provider account ids for analytics
26+
const providerAccountKey = await deriveKey(masterSecret, 'Happy Coder', ['analytics', 'provider-account']);
27+
2528
// Derive master blob key for legacy sessions (those with no per-session dataKey)
2629
const masterBlobKey = await deriveKey(masterSecret, 'Happy Blobs', ['master']);
2730

2831
// Create encryption
29-
return new Encryption(anonID, masterSecret, contentKeyPair, masterBlobKey);
32+
return new Encryption(anonID, providerAccountKey, masterSecret, contentKeyPair, masterBlobKey);
3033
}
3134

3235
private readonly legacyEncryption: SecretBoxEncryption;
3336
private readonly contentKeyPair: sodium.KeyPair;
3437
private readonly masterBlobKey: Uint8Array;
3538
readonly anonID: string;
39+
readonly providerAccountKey: Uint8Array;
3640
readonly contentDataKey: Uint8Array;
3741

3842
// Session and machine encryption management
@@ -42,8 +46,9 @@ export class Encryption {
4246
private sessionDataKeys = new Map<string, Uint8Array>();
4347
private cache: EncryptionCache;
4448

45-
private constructor(anonID: string, masterSecret: Uint8Array, contentKeyPair: sodium.KeyPair, masterBlobKey: Uint8Array) {
49+
private constructor(anonID: string, providerAccountKey: Uint8Array, masterSecret: Uint8Array, contentKeyPair: sodium.KeyPair, masterBlobKey: Uint8Array) {
4650
this.anonID = anonID;
51+
this.providerAccountKey = providerAccountKey;
4752
this.contentKeyPair = contentKeyPair;
4853
this.legacyEncryption = new SecretBoxEncryption(masterSecret);
4954
this.masterBlobKey = masterBlobKey;

‎packages/happy-app/sources/sync/storageTypes.spec.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ describe('MetadataSchema', () => {
1111
expect(MetadataSchema.parse(rigMetadataFixture).bot).toBeUndefined();
1212
});
1313

14+
it('reads the task depth and drops a bad one without losing the metadata', () => {
15+
expect(MetadataSchema.parse({ ...rigMetadataFixture, depth: 1 }).depth).toBe(1);
16+
expect(MetadataSchema.parse(rigMetadataFixture).depth).toBeUndefined();
17+
for (const depth of [-1, 1.5, '2', null]) {
18+
const parsed = MetadataSchema.safeParse({ ...rigMetadataFixture, depth });
19+
expect(parsed.success).toBe(true);
20+
expect(parsed.data?.depth).toBeUndefined();
21+
expect(parsed.data?.path).toBe(rigMetadataFixture.path);
22+
}
23+
});
24+
1425
it('preserves archive lifecycle metadata', () => {
1526
const metadata = MetadataSchema.parse({
1627
path: '/tmp/project',

‎packages/happy-app/sources/sync/storageTypes.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ export const MetadataSchema = z.object({
220220
* inside the parent session's sidebar panel (see `useSideChatSession`).
221221
*/
222222
isSideChat: z.boolean().optional(),
223+
/**
224+
* How deep a Happy Agent session sits under its root: 0 for a bot or
225+
* project session, 1 for a subtask. Absent from the CLI and older Happy
226+
* Agents; a bad value is dropped rather than failing the whole parse.
227+
*/
228+
depth: z.number().int().nonnegative().optional().catch(undefined),
223229
/**
224230
* Per-session permission / model / effort picks made in any client.
225231
* Synced through session metadata so every device shows the same

‎packages/happy-app/sources/sync/sync.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,9 @@ class Sync {
937937
rigComposerClear(sessionId);
938938
}
939939
options?.onAccepted?.();
940-
trackMessageSent(source, session.metadata, modeMeta);
940+
const machineId = session.metadata?.machineId;
941+
trackMessageSent(source, session.metadata, modeMeta,
942+
machineId ? storage.getState().machines[machineId]?.metadata?.platform : null);
941943

942944
// Stamp local activity time so the (opt-in) activity sort bubbles this session
943945
// up on user action only — not on background agent output.
@@ -3323,7 +3325,7 @@ async function syncInit(credentials: AuthCredentials, restore: boolean) {
33233325
const encryption = await Encryption.create(secretKey);
33243326

33253327
// Initialize tracking
3326-
initializeTracking(encryption.anonID);
3328+
initializeTracking(encryption.anonID, encryption.providerAccountKey);
33273329

33283330
// Initialize socket connection
33293331
const API_ENDPOINT = getServerUrl();

‎packages/happy-app/sources/track/index.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@ import * as Updates from 'expo-updates';
33
import { tracking } from './tracking';
44
import type { Metadata, Session } from '@/sync/storageTypes';
55
import type { MessageModeMeta } from '@/sync/messageMeta';
6-
import { messageSentSessionProperties } from './messageSentProperties';
6+
import { Platform } from 'react-native';
7+
import { isTauri } from '@/utils/isTauri';
8+
import { messageSentClient, messageSentSessionProperties } from './messageSentProperties';
9+
import { providerAccountHash } from './providerAccountHash';
710

811
// Re-export tracking for direct access
912
export { tracking } from './tracking';
1013

14+
/** Keys `provider_account_hash`; set with the account, cleared on logout. */
15+
let providerAccountKey: Uint8Array | null = null;
16+
1117
/**
1218
* Initialize tracking with an anonymous user ID.
1319
* Should be called once during auth initialization.
1420
*/
15-
export function initializeTracking(anonymousUserId: string) {
21+
export function initializeTracking(anonymousUserId: string, accountKey: Uint8Array) {
22+
providerAccountKey = accountKey;
1623
tracking?.identify(anonymousUserId, { name: anonymousUserId });
1724
}
1825

@@ -28,6 +35,7 @@ export function trackAccountRestored() {
2835
}
2936

3037
export function trackLogout() {
38+
providerAccountKey = null;
3139
tracking?.reset();
3240
}
3341

@@ -49,13 +57,23 @@ export function trackSessionSwitched(session: Pick<Session, 'id' | 'createdAt' |
4957

5058
export type MessageSentSource = 'chat' | 'new_session' | 'option' | 'question' | 'voice';
5159

52-
export function trackMessageSent(source: MessageSentSource, metadata?: Metadata | null, mode?: MessageModeMeta | null) {
53-
tracking?.capture('message_sent', {
54-
...messageSentSessionProperties(metadata, mode),
60+
export function trackMessageSent(
61+
source: MessageSentSource,
62+
metadata?: Metadata | null,
63+
mode?: MessageModeMeta | null,
64+
machinePlatform?: string | null,
65+
) {
66+
if (!tracking) return;
67+
const key = providerAccountKey;
68+
tracking.capture('message_sent', {
69+
...messageSentSessionProperties(
70+
messageSentClient(Platform.OS, isTauri()),
71+
metadata,
72+
mode,
73+
machinePlatform,
74+
key ? (providerId) => providerAccountHash(key, providerId) : undefined,
75+
),
5576
source,
56-
session_agent: metadata?.flavor === 'gpt' || metadata?.flavor === 'openai'
57-
? 'codex'
58-
: metadata?.flavor ?? null,
5977
session_started_source: metadata?.startedBy === 'daemon' || metadata?.startedFromDaemon === true
6078
? 'daemon'
6179
: metadata?.startedBy === 'terminal' || metadata?.startedFromDaemon === false
Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,137 @@
11
import { describe, expect, it } from 'vitest';
22
import { rigMetadataFixture } from '@/sync/__testdata__/rigMetadata';
33
import type { Metadata } from '@/sync/storageTypes';
4-
import { messageSentSessionProperties } from './messageSentProperties';
4+
import { messageSentClient, messageSentSessionProperties } from './messageSentProperties';
5+
6+
const props = (
7+
metadata: Metadata | null | undefined,
8+
mode: Parameters<typeof messageSentSessionProperties>[2],
9+
machinePlatform?: string | null,
10+
) => messageSentSessionProperties('ios', metadata, mode, machinePlatform);
11+
12+
const bot = { id: 'bot-1', name: 'Chief of Staff', username: 'chief', workspaceId: 'w-1', orderKey: '1' };
513

614
describe('messageSentSessionProperties', () => {
715
it('reports Happy Agent version, model, provider kind and effort from the sent mode', () => {
8-
expect(messageSentSessionProperties(rigMetadataFixture, {
16+
expect(messageSentSessionProperties('desktop', rigMetadataFixture, {
917
model: 'shared-model',
1018
modelProviderId: 'claude',
1119
effort: 'max',
12-
})).toEqual({
20+
}, null, (providerId) => `hash(${providerId})`)).toEqual({
21+
client: 'desktop',
22+
target: 'session',
23+
bot_system_key: null,
24+
task_depth: null,
1325
session_client: 'happy_agent',
1426
happy_agent_version: '0.0.30',
1527
model: 'shared-model',
1628
model_provider_kind: 'claude',
29+
provider_account_hash: 'hash(claude)',
1730
effort: 'max',
31+
agent_os: 'mac',
1832
});
1933
});
2034

21-
it('reports a custom provider by kind, not by its own name', () => {
22-
const metadata: Metadata = { ...rigMetadataFixture, models: [], providers: [], provider: undefined };
23-
expect(messageSentSessionProperties(metadata, { model: 'm', modelProviderId: 'my_private_proxy' }).model_provider_kind)
24-
.toBe('custom');
35+
it('hashes the account the message went to, or the session provider, and only for Happy Agent', () => {
36+
const hash = (providerId: string) => `hash(${providerId})`;
37+
const send = (metadata: Metadata, modelProviderId?: string) =>
38+
messageSentSessionProperties('ios', metadata, { modelProviderId }, null, hash).provider_account_hash;
39+
expect(send(rigMetadataFixture, 'claude_extra')).toBe('hash(claude_extra)');
40+
expect(send(rigMetadataFixture)).toBe('hash(codex)');
41+
expect(send({ ...rigMetadataFixture, provider: undefined })).toBeNull();
42+
expect(send({ path: '/r', host: 'h', flavor: 'claude' } as Metadata, 'claude')).toBeNull();
43+
expect(props(rigMetadataFixture, { modelProviderId: 'claude' }).provider_account_hash).toBeNull();
44+
});
45+
46+
it('reports the task depth only when it is a whole number from zero up', () => {
47+
const depth = (value: unknown) => props({ ...rigMetadataFixture, depth: value } as Metadata, {}).task_depth;
48+
expect(depth(0)).toBe(0);
49+
expect(depth(2)).toBe(2);
50+
expect(depth(undefined)).toBeNull();
51+
expect(depth(-1)).toBeNull();
52+
expect(depth(1.5)).toBeNull();
53+
expect(depth('2')).toBeNull();
54+
});
55+
56+
it('reports the provider by its configured type as published, never by its account id', () => {
57+
const model = (providerId: string, providerKind: string) => ({ providerId, providerKind, id: 'm', code: 'm', value: 'M' });
58+
const kind = (metadata: Metadata, providerId: string) =>
59+
props(metadata, { model: 'm', modelProviderId: providerId }).model_provider_kind;
60+
const bare: Metadata = { ...rigMetadataFixture, models: [], providers: [], provider: undefined };
61+
expect(kind({ ...bare, models: [model('my_bedrock', 'bedrock')] }, 'my_bedrock')).toBe('bedrock');
62+
expect(kind({ ...bare, providers: [{ id: 'my_router', kind: 'openrouter', name: 'Mine' }] }, 'my_router')).toBe('openrouter');
63+
// An older Happy Agent's own `custom` passes through unmapped.
64+
expect(kind({ ...bare, models: [model('my_proxy', 'custom')] }, 'my_proxy')).toBe('custom');
65+
});
66+
67+
it('reports no provider kind rather than inventing one', () => {
68+
const bare: Metadata = { ...rigMetadataFixture, models: [], providers: [], provider: undefined };
69+
expect(props(bare, { model: 'm', modelProviderId: 'unknown_account' }).model_provider_kind).toBeNull();
70+
const kindless: Metadata = { ...bare, models: [{ providerId: 'p', id: 'm', code: 'm', value: 'M' }] };
71+
expect(props(kindless, { model: 'm', modelProviderId: 'p' }).model_provider_kind).toBeNull();
2572
});
2673

2774
it('marks CLI sessions and leaves Happy Agent fields empty', () => {
2875
const metadata = { path: '/repo', host: 'mac', version: '1.2.5', flavor: 'codex' } as Metadata;
29-
expect(messageSentSessionProperties(metadata, { model: 'gpt-5.6-sol', effort: 'high' })).toEqual({
76+
expect(props(metadata, { model: 'gpt-5.6-sol', effort: 'high' })).toEqual({
77+
client: 'ios',
78+
target: 'session',
79+
bot_system_key: null,
80+
task_depth: null,
3081
session_client: 'cli',
3182
happy_agent_version: null,
3283
model: 'gpt-5.6-sol',
3384
model_provider_kind: null,
85+
provider_account_hash: null,
3486
effort: 'high',
87+
agent_os: null,
3588
});
3689
});
3790

91+
it('reads the OS from the session, as Happy Agent and the CLI each write it', () => {
92+
const os = (value: string) => props({ path: '/r', host: 'h', os: value } as Metadata, {}).agent_os;
93+
expect(os('darwin 25.6.0')).toBe('mac');
94+
expect(os('win32 10.0.26100')).toBe('win');
95+
expect(os('linux')).toBe('linux');
96+
expect(os('freebsd')).toBe('other');
97+
});
98+
99+
it('falls back to the machine platform when the session has no OS', () => {
100+
const metadata = { path: '/r', host: 'h' } as Metadata;
101+
expect(props(metadata, {}, 'win32').agent_os).toBe('win');
102+
expect(props({ ...metadata, os: 'linux 6.8' }, {}, 'darwin').agent_os).toBe('linux');
103+
expect(props(metadata, {}, ' ').agent_os).toBeNull();
104+
});
105+
106+
it('tells bots and the Chief of Staff apart only by the bot system key', () => {
107+
const target = (metadata: Metadata) => props(metadata, {}).target;
108+
expect(target({ ...rigMetadataFixture, bot })).toBeNull();
109+
expect(target({ ...rigMetadataFixture, bot: { ...bot, systemKey: 'chief_of_staff' } })).toBe('chief_of_staff');
110+
expect(target({ ...rigMetadataFixture, bot: { ...bot, systemKey: null } })).toBe('bot');
111+
expect(target({ ...rigMetadataFixture, bot: { ...bot, systemKey: 'future_system_bot' } })).toBe('bot');
112+
expect(props(null, undefined).target).toBeNull();
113+
});
114+
115+
it('reports the raw system key of a system bot, never its name or id', () => {
116+
const key = (metadata: Metadata) => props(metadata, {}).bot_system_key;
117+
expect(key({ ...rigMetadataFixture, bot: { ...bot, systemKey: 'chief_of_staff' } })).toBe('chief_of_staff');
118+
expect(key({ ...rigMetadataFixture, bot: { ...bot, systemKey: null } })).toBeNull();
119+
expect(key({ ...rigMetadataFixture, bot })).toBeNull();
120+
expect(key(rigMetadataFixture)).toBeNull();
121+
});
122+
38123
it('reports null model and effort when the agent default applies', () => {
39124
const metadata = { path: '/repo', host: 'mac', flavor: 'claude' } as Metadata;
40-
expect(messageSentSessionProperties(metadata, {})).toMatchObject({ model: null, effort: null });
41-
expect(messageSentSessionProperties(null, undefined).session_client).toBeNull();
125+
expect(props(metadata, {})).toMatchObject({ model: null, effort: null });
126+
expect(props(null, undefined).session_client).toBeNull();
127+
});
128+
});
129+
130+
describe('messageSentClient', () => {
131+
it('names the phone by its OS and the web build by where it runs', () => {
132+
expect(messageSentClient('ios', false)).toBe('ios');
133+
expect(messageSentClient('android', false)).toBe('android');
134+
expect(messageSentClient('web', false)).toBe('web');
135+
expect(messageSentClient('web', true)).toBe('desktop');
42136
});
43137
});

0 commit comments

Comments
 (0)