mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a08ec95338
commit
ba88d8cfc3
@@ -35,8 +35,9 @@ const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
|
||||
|
||||
const PrivacyPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot, setAnalyticsEnabled } = useCoreState();
|
||||
const { snapshot, setAnalyticsEnabled, setMeetAutoOrchestratorHandoff } = useCoreState();
|
||||
const analyticsEnabled = snapshot.analyticsEnabled;
|
||||
const meetAutoHandoff = snapshot.meetAutoOrchestratorHandoff;
|
||||
|
||||
const [capabilities, setCapabilities] = useState<AnnotatedCapability[]>([]);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
@@ -73,6 +74,15 @@ const PrivacyPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMeetAutoHandoff = async () => {
|
||||
const newValue = !meetAutoHandoff;
|
||||
try {
|
||||
await setMeetAutoOrchestratorHandoff(newValue);
|
||||
} catch (error) {
|
||||
console.warn('[privacy] failed to persist meet auto-handoff setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="settings-privacy-panel">
|
||||
<SettingsHeader
|
||||
@@ -168,6 +178,42 @@ const PrivacyPanel = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meeting Follow-ups Section (#1299) */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
|
||||
Meeting follow-ups
|
||||
</h3>
|
||||
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex-1 mr-4">
|
||||
<p className="text-sm font-medium text-stone-900">
|
||||
Auto-handoff Google Meet transcripts to the orchestrator
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
|
||||
When a Google Meet call ends, OpenHuman’s orchestrator can read the
|
||||
transcript and may take actions like drafting messages, scheduling follow-ups,
|
||||
or posting summaries to your connected Slack workspace. Off by default.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="privacy-meet-handoff-toggle"
|
||||
onClick={handleToggleMeetAutoHandoff}
|
||||
aria-label="Auto-handoff Google Meet transcripts to the orchestrator"
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={meetAutoHandoff}>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
meetAutoHandoff ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
|
||||
<div className="flex items-start space-x-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
@@ -7,8 +7,13 @@ import PrivacyPanel from '../PrivacyPanel';
|
||||
|
||||
vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({ listCapabilities: vi.fn() }));
|
||||
|
||||
const setMeetAutoOrchestratorHandoffMock = vi.fn();
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({ snapshot: { analyticsEnabled: false }, setAnalyticsEnabled: vi.fn() }),
|
||||
useCoreState: () => ({
|
||||
snapshot: { analyticsEnabled: false, meetAutoOrchestratorHandoff: false },
|
||||
setAnalyticsEnabled: vi.fn(),
|
||||
setMeetAutoOrchestratorHandoff: (v: boolean) => setMeetAutoOrchestratorHandoffMock(v),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
@@ -88,7 +93,21 @@ describe('PrivacyPanel', () => {
|
||||
expect(screen.getByTestId('privacy-load-error')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId('privacy-capability-list')).toBeNull();
|
||||
// Analytics toggle still rendered
|
||||
expect(screen.getByRole('switch')).toBeTruthy();
|
||||
// Analytics + meet-handoff toggles still rendered
|
||||
expect(screen.getAllByRole('switch').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('flips the meet auto-handoff toggle from OFF to ON when clicked (#1299)', async () => {
|
||||
vi.mocked(listCapabilities).mockResolvedValue([]);
|
||||
renderWithProviders(<PrivacyPanel />);
|
||||
|
||||
const toggle = await screen.findByTestId('privacy-meet-handoff-toggle');
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setMeetAutoOrchestratorHandoffMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
|
||||
onboardingCompleted: true,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
...overrides,
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface CoreAppSnapshot {
|
||||
*/
|
||||
chatOnboardingCompleted: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
/**
|
||||
* Whether ending a Google Meet call hands the transcript to the
|
||||
* orchestrator agent for proactive follow-up actions (drafting Slack
|
||||
* messages, scheduling, etc.). Mirrors
|
||||
* `Config::meet.auto_orchestrator_handoff` in the Rust core (see
|
||||
* `src/openhuman/config/schema/meet.rs`). Defaults to `false` —
|
||||
* privacy-conservative gate added in #1299. The webview meet flow
|
||||
* reads this before invoking `handoffToOrchestrator`.
|
||||
*/
|
||||
meetAutoOrchestratorHandoff: boolean;
|
||||
localState: CoreLocalState;
|
||||
runtime: CoreRuntimeSnapshot;
|
||||
}
|
||||
@@ -66,6 +76,7 @@ const emptySnapshot: CoreAppSnapshot = {
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { loadThreads, resetThreadCachesPreservingSelection } from '../store/thre
|
||||
import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage';
|
||||
import {
|
||||
openhumanUpdateAnalyticsSettings,
|
||||
openhumanUpdateMeetSettings,
|
||||
restartApp,
|
||||
setOnboardingCompleted,
|
||||
storeSession,
|
||||
@@ -66,6 +67,7 @@ interface CoreStateContextValue extends CoreState {
|
||||
refreshTeamMembers: (teamId: string) => Promise<void>;
|
||||
refreshTeamInvites: (teamId: string) => Promise<void>;
|
||||
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
|
||||
setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise<void>;
|
||||
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
|
||||
setEncryptionKey: (value: string | null) => Promise<void>;
|
||||
setPrimaryWalletAddress: (value: string | null) => Promise<void>;
|
||||
@@ -142,6 +144,7 @@ function normalizeSnapshot(
|
||||
onboardingCompleted: result.onboardingCompleted,
|
||||
chatOnboardingCompleted: result.chatOnboardingCompleted,
|
||||
analyticsEnabled: result.analyticsEnabled,
|
||||
meetAutoOrchestratorHandoff: result.meetAutoOrchestratorHandoff ?? false,
|
||||
localState: {
|
||||
encryptionKey: result.localState.encryptionKey ?? null,
|
||||
primaryWalletAddress: result.localState.primaryWalletAddress ?? null,
|
||||
@@ -479,6 +482,22 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
[commitState, refresh]
|
||||
);
|
||||
|
||||
const setMeetAutoOrchestratorHandoff = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
await openhumanUpdateMeetSettings({ auto_orchestrator_handoff: enabled });
|
||||
// Optimistic commit so the toggle flips instantly; full snapshot
|
||||
// refresh follows so the cached value matches what core just wrote.
|
||||
commitState(previous => ({
|
||||
...previous,
|
||||
snapshot: { ...previous.snapshot, meetAutoOrchestratorHandoff: enabled },
|
||||
}));
|
||||
await refresh().catch(err => {
|
||||
log('refresh failed after setMeetAutoOrchestratorHandoff: %O', sanitizeError(err));
|
||||
});
|
||||
},
|
||||
[commitState, refresh]
|
||||
);
|
||||
|
||||
const setOnboardingCompletedFlag = useCallback(
|
||||
async (value: boolean) => {
|
||||
await setOnboardingCompleted(value);
|
||||
@@ -567,6 +586,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
refreshTeamInvites,
|
||||
patchSnapshot,
|
||||
setAnalyticsEnabled,
|
||||
setMeetAutoOrchestratorHandoff,
|
||||
setOnboardingCompletedFlag,
|
||||
setEncryptionKey: value => updateLocalState({ encryptionKey: value }),
|
||||
setPrimaryWalletAddress: value => updateLocalState({ primaryWalletAddress: value }),
|
||||
@@ -582,6 +602,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
refreshTeams,
|
||||
patchSnapshot,
|
||||
setAnalyticsEnabled,
|
||||
setMeetAutoOrchestratorHandoff,
|
||||
setOnboardingCompletedFlag,
|
||||
state,
|
||||
storeSessionToken,
|
||||
|
||||
@@ -74,6 +74,7 @@ function resetCoreStateStore() {
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as coreStateApi from '../../services/coreStateApi';
|
||||
import * as tauriCommands from '../../utils/tauriCommands';
|
||||
import { setCoreStateSnapshot } from '../../lib/coreState/store';
|
||||
import CoreStateProvider, { useCoreState } from '../CoreStateProvider';
|
||||
|
||||
@@ -78,6 +79,7 @@ function resetCoreStateStore() {
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
@@ -241,4 +243,68 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
|
||||
expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' })
|
||||
);
|
||||
});
|
||||
|
||||
it('setMeetAutoOrchestratorHandoff(true) calls update RPC + flips snapshot optimistically (#1299)', async () => {
|
||||
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
|
||||
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
} as never);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
expect(ctx?.snapshot.meetAutoOrchestratorHandoff).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await ctx!.setMeetAutoOrchestratorHandoff(true);
|
||||
});
|
||||
|
||||
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
|
||||
auto_orchestrator_handoff: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => {
|
||||
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
|
||||
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
} as never);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
fetchSnapshot.mockRejectedValueOnce(new Error('refresh failed'));
|
||||
|
||||
await act(async () => {
|
||||
await expect(ctx!.setMeetAutoOrchestratorHandoff(false)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
|
||||
auto_orchestrator_handoff: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Privacy gate regression tests for issue #1299.
|
||||
*
|
||||
* Verifies that `maybeHandoffToOrchestrator` only invokes the orchestrator
|
||||
* (creating a fresh chat thread + sending the transcript prompt) when the
|
||||
* user has explicitly opted in via the `meet.auto_orchestrator_handoff`
|
||||
* setting. Default-OFF must skip both `threadApi.createNewThread` and
|
||||
* `chatSend` entirely. RPC failures fail closed (no handoff).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { __testInternals } from '../webviewAccountService';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
isTauri: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(() => {}) }));
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() }));
|
||||
|
||||
const createNewThreadMock = vi.fn();
|
||||
vi.mock('../api/threadApi', () => ({
|
||||
threadApi: { createNewThread: (...args: unknown[]) => createNewThreadMock(...args) },
|
||||
}));
|
||||
|
||||
const chatSendMock = vi.fn();
|
||||
vi.mock('../chatService', () => ({ chatSend: (...args: unknown[]) => chatSendMock(...args) }));
|
||||
|
||||
const getMeetSettingsMock = vi.fn();
|
||||
vi.mock('../../utils/tauriCommands/config', () => ({
|
||||
openhumanGetMeetSettings: () => getMeetSettingsMock(),
|
||||
}));
|
||||
|
||||
interface MockMeetingSession {
|
||||
code: string;
|
||||
startedAt: number;
|
||||
snapshots: never[];
|
||||
}
|
||||
|
||||
function makeSession(): MockMeetingSession {
|
||||
return { code: 'abc-defg-hij', startedAt: Date.now() - 60_000, snapshots: [] };
|
||||
}
|
||||
|
||||
describe('maybeHandoffToOrchestrator (#1299 privacy gate)', () => {
|
||||
beforeEach(() => {
|
||||
createNewThreadMock.mockReset();
|
||||
chatSendMock.mockReset();
|
||||
getMeetSettingsMock.mockReset();
|
||||
createNewThreadMock.mockResolvedValue({ id: 'thread-1' });
|
||||
chatSendMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('skips handoff when auto_orchestrator_handoff is false', async () => {
|
||||
getMeetSettingsMock.mockResolvedValue({
|
||||
result: { auto_orchestrator_handoff: false },
|
||||
logs: [],
|
||||
});
|
||||
|
||||
await __testInternals.maybeHandoffToOrchestrator(
|
||||
'acct-test',
|
||||
// The function only reads `code` and `startedAt`. Ts cast is enough
|
||||
// for a structural mock — full MeetingSession is heavier than needed.
|
||||
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
|
||||
Date.now(),
|
||||
'## Transcript\n[10:00:00] Alice: hello',
|
||||
new Set(['Alice'])
|
||||
);
|
||||
|
||||
expect(createNewThreadMock).not.toHaveBeenCalled();
|
||||
expect(chatSendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips handoff when settings field is missing', async () => {
|
||||
getMeetSettingsMock.mockResolvedValue({ result: {}, logs: [] });
|
||||
|
||||
await __testInternals.maybeHandoffToOrchestrator(
|
||||
'acct-test',
|
||||
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
|
||||
Date.now(),
|
||||
'## Transcript',
|
||||
new Set()
|
||||
);
|
||||
|
||||
expect(createNewThreadMock).not.toHaveBeenCalled();
|
||||
expect(chatSendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed (no handoff) when settings RPC throws', async () => {
|
||||
getMeetSettingsMock.mockRejectedValue(new Error('core rpc down'));
|
||||
|
||||
await __testInternals.maybeHandoffToOrchestrator(
|
||||
'acct-test',
|
||||
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
|
||||
Date.now(),
|
||||
'## Transcript',
|
||||
new Set()
|
||||
);
|
||||
|
||||
expect(createNewThreadMock).not.toHaveBeenCalled();
|
||||
expect(chatSendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires handoff when auto_orchestrator_handoff is true', async () => {
|
||||
getMeetSettingsMock.mockResolvedValue({
|
||||
result: { auto_orchestrator_handoff: true },
|
||||
logs: [],
|
||||
});
|
||||
|
||||
await __testInternals.maybeHandoffToOrchestrator(
|
||||
'acct-test',
|
||||
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
|
||||
Date.now(),
|
||||
'## Transcript\n[10:00:00] Alice: hello',
|
||||
new Set(['Alice'])
|
||||
);
|
||||
|
||||
expect(createNewThreadMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatSendMock).toHaveBeenCalledTimes(1);
|
||||
const sendArgs = chatSendMock.mock.calls[0][0];
|
||||
expect(sendArgs.threadId).toBe('thread-1');
|
||||
expect(sendArgs.message).toContain('## Transcript');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,13 @@ interface AppStateSnapshotResult {
|
||||
onboardingCompleted: boolean;
|
||||
chatOnboardingCompleted: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
/**
|
||||
* Mirror of `Config::meet.auto_orchestrator_handoff` (#1299). Older
|
||||
* core builds may omit the field on the wire — `fetchCoreAppSnapshot`
|
||||
* normalises the missing case to `false` before returning so callers
|
||||
* never observe `undefined` here.
|
||||
*/
|
||||
meetAutoOrchestratorHandoff?: boolean;
|
||||
localState: {
|
||||
encryptionKey?: string | null;
|
||||
primaryWalletAddress?: string | null;
|
||||
@@ -50,7 +57,13 @@ export const fetchCoreAppSnapshot = async (): Promise<AppStateSnapshotResult> =>
|
||||
const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({
|
||||
method: 'openhuman.app_state_snapshot',
|
||||
});
|
||||
return response.result;
|
||||
// Normalise the optional #1299 field at the API boundary so older core
|
||||
// builds without `meetAutoOrchestratorHandoff` still surface the
|
||||
// privacy-conservative `false` to callers (e.g. CoreStateProvider).
|
||||
return {
|
||||
...response.result,
|
||||
meetAutoOrchestratorHandoff: response.result.meetAutoOrchestratorHandoff ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const updateCoreLocalState = async (params: UpdateCoreLocalStateParams): Promise<void> => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { addIntegrationNotification } from '../store/notificationSlice';
|
||||
import { fetchRespondQueue } from '../store/providerSurfaceSlice';
|
||||
import type { AccountProvider, IngestedMessage } from '../types/accounts';
|
||||
import { openhumanGetMeetSettings } from '../utils/tauriCommands/config';
|
||||
import { threadApi } from './api/threadApi';
|
||||
import { chatSend } from './chatService';
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
@@ -647,7 +648,7 @@ async function flushMeetingSession(
|
||||
);
|
||||
|
||||
if (segments.length > 0) {
|
||||
await handoffToOrchestrator(accountId, session, endedAt, markdown, participants);
|
||||
await maybeHandoffToOrchestrator(accountId, session, endedAt, markdown, participants);
|
||||
}
|
||||
} catch (err) {
|
||||
errLog('meet: memory write failed: %o', err);
|
||||
@@ -664,6 +665,45 @@ async function flushMeetingSession(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy gate (#1299) — only call `handoffToOrchestrator` when the
|
||||
* user has explicitly opted in via the `meet.auto_orchestrator_handoff`
|
||||
* setting. Without this gate, every Meet call ended would auto-feed the
|
||||
* transcript to the orchestrator, which has the full Slack tool surface
|
||||
* and would proactively post summaries to public channels (e.g.
|
||||
* `#general`) without consent.
|
||||
*
|
||||
* The setting is read fresh per call rather than cached so toggle
|
||||
* changes mid-session take effect immediately. Failure to read settings
|
||||
* (e.g. core RPC down) is treated as opt-out — privacy-conservative.
|
||||
*/
|
||||
async function maybeHandoffToOrchestrator(
|
||||
accountId: string,
|
||||
session: MeetingSession,
|
||||
endedAt: number,
|
||||
transcriptMarkdown: string,
|
||||
participants: Set<string>
|
||||
): Promise<void> {
|
||||
let optedIn = false;
|
||||
try {
|
||||
const settings = await openhumanGetMeetSettings();
|
||||
optedIn = settings?.result?.auto_orchestrator_handoff === true;
|
||||
} catch (err) {
|
||||
// Fail-closed: if we can't read the setting, do not hand off.
|
||||
errLog('meet: failed to read meet settings, defaulting to OFF: %o', err);
|
||||
}
|
||||
|
||||
if (!optedIn) {
|
||||
log(
|
||||
'meet: orchestrator handoff disabled (auto_orchestrator_handoff !== true), skipping for code=%s',
|
||||
session.code
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await handoffToOrchestrator(accountId, session, endedAt, transcriptMarkdown, participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a meeting transcript is persisted, open a fresh thread with the
|
||||
* orchestrator agent and hand it the transcript so it can extract notes
|
||||
@@ -672,6 +712,10 @@ async function flushMeetingSession(
|
||||
* The orchestrator IS the LLM here — there's no separate summarisation
|
||||
* call. It produces structured notes inline as part of its reply and
|
||||
* routes any actionable items to its subagents/skills.
|
||||
*
|
||||
* IMPORTANT: This function is the privacy-sensitive path. Callers must
|
||||
* gate it on user opt-in via {@link maybeHandoffToOrchestrator} — do
|
||||
* NOT invoke it directly from session-end code paths. See #1299.
|
||||
*/
|
||||
async function handoffToOrchestrator(
|
||||
accountId: string,
|
||||
@@ -1058,3 +1102,6 @@ async function flushMeetingIfAny(accountId: string, reason: string): Promise<voi
|
||||
activeMeetings.delete(accountId);
|
||||
await flushMeetingSession(accountId, session, Date.now(), reason);
|
||||
}
|
||||
|
||||
/** Test-only re-exports — do NOT import outside `__tests__/`. */
|
||||
export const __testInternals = { maybeHandoffToOrchestrator };
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeCoreState(token: string | null): CoreState {
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
|
||||
@@ -111,6 +111,15 @@ vi.mock('../utils/tauriCommands', () => ({
|
||||
.fn()
|
||||
.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }),
|
||||
openhumanAgentServerStatus: vi.fn().mockResolvedValue({ result: { running: true }, logs: [] }),
|
||||
openhumanUpdateMeetSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
}),
|
||||
openhumanGetMeetSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ result: { auto_orchestrator_handoff: false }, logs: [] }),
|
||||
exchangeToken: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -10,12 +10,16 @@ describe('tauriCommands/config', () => {
|
||||
const mockIsTauri = isTauri as Mock;
|
||||
const mockCallCoreRpc = callCoreRpc as Mock;
|
||||
let openhumanUpdateLocalAiSettings: typeof import('./config').openhumanUpdateLocalAiSettings;
|
||||
let openhumanUpdateMeetSettings: typeof import('./config').openhumanUpdateMeetSettings;
|
||||
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
const actual = await vi.importActual<typeof import('./config')>('./config');
|
||||
openhumanUpdateLocalAiSettings = actual.openhumanUpdateLocalAiSettings;
|
||||
openhumanUpdateMeetSettings = actual.openhumanUpdateMeetSettings;
|
||||
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,4 +48,43 @@ describe('tauriCommands/config', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanUpdateMeetSettings (#1299)', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(
|
||||
openhumanUpdateMeetSettings({ auto_orchestrator_handoff: true })
|
||||
).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('forwards the patch to openhuman.config_update_meet_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
});
|
||||
await openhumanUpdateMeetSettings({ auto_orchestrator_handoff: true });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.config_update_meet_settings',
|
||||
params: { auto_orchestrator_handoff: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanGetMeetSettings (#1299)', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(openhumanGetMeetSettings()).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads via openhuman.config_get_meet_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { auto_orchestrator_handoff: true }, logs: [] });
|
||||
const out = await openhumanGetMeetSettings();
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.config_get_meet_settings',
|
||||
});
|
||||
expect(out.result.auto_orchestrator_handoff).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +204,29 @@ export async function openhumanGetAnalyticsSettings(): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanUpdateMeetSettings(update: {
|
||||
auto_orchestrator_handoff?: boolean;
|
||||
}): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
|
||||
method: 'openhuman.config_update_meet_settings',
|
||||
params: update,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanGetMeetSettings(): Promise<
|
||||
CommandResponse<{ auto_orchestrator_handoff: boolean }>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<{ auto_orchestrator_handoff: boolean }>>({
|
||||
method: 'openhuman.config_get_meet_settings',
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanGetRuntimeFlags(): Promise<CommandResponse<RuntimeFlags>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
|
||||
@@ -83,6 +83,11 @@ pub struct AppStateSnapshot {
|
||||
/// `complete_onboarding(action="complete")`.
|
||||
pub chat_onboarding_completed: bool,
|
||||
pub analytics_enabled: bool,
|
||||
/// Mirror of `Config::meet.auto_orchestrator_handoff` — gates whether
|
||||
/// ending a Google Meet call hands the transcript to the orchestrator
|
||||
/// agent for proactive follow-up actions. Default `false`. See
|
||||
/// issue #1299.
|
||||
pub meet_auto_orchestrator_handoff: bool,
|
||||
pub local_state: StoredAppState,
|
||||
pub runtime: RuntimeSnapshot,
|
||||
}
|
||||
@@ -464,11 +469,12 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
let runtime = build_runtime_snapshot(&config).await;
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} snapshot auth={} onboarding={} chat_onboarding={} analytics={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
|
||||
"{LOG_PREFIX} snapshot auth={} onboarding={} chat_onboarding={} analytics={} meet_handoff={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
|
||||
auth.is_authenticated,
|
||||
config.onboarding_completed,
|
||||
config.chat_onboarding_completed,
|
||||
config.observability.analytics_enabled,
|
||||
config.meet.auto_orchestrator_handoff,
|
||||
local_state.primary_wallet_address.is_some(),
|
||||
runtime.screen_intelligence.session.active,
|
||||
runtime.local_ai.state,
|
||||
@@ -484,6 +490,7 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
onboarding_completed: config.onboarding_completed,
|
||||
chat_onboarding_completed: config.chat_onboarding_completed,
|
||||
analytics_enabled: config.observability.analytics_enabled,
|
||||
meet_auto_orchestrator_handoff: config.meet.auto_orchestrator_handoff,
|
||||
local_state,
|
||||
runtime,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ Authoritative TOML-backed configuration layer. Owns the `Config` schema (every d
|
||||
- Workspace identity helpers: `pub fn clear_active_user`, `default_root_openhuman_dir`, `pre_login_user_dir`, `read_active_user_id`, `user_openhuman_dir`, `write_active_user_id`, `PRE_LOGIN_USER_ID` — `schema/identity_cost.rs`.
|
||||
- `pub mod ops` (re-exported as `rpc`) — `ops.rs` — RPC handlers and settings mutation.
|
||||
- `pub mod settings_cli` — `settings_cli.rs` — `openhuman settings ...` CLI surface.
|
||||
- RPC `config.{get_config, update_model_settings, update_memory_settings, update_screen_intelligence_settings, update_runtime_settings, update_browser_settings, resolve_api_url, get_runtime_flags, set_browser_allow_all, workspace_onboarding_flag_exists, workspace_onboarding_flag_set, update_analytics_settings, get_analytics_settings, agent_server_status, reset_local_data, get_onboarding_completed, get_dictation_settings, update_dictation_settings, get_voice_server_settings, update_voice_server_settings, set_onboarding_completed}` — `schemas.rs`.
|
||||
- RPC `config.{get_config, update_model_settings, update_memory_settings, update_screen_intelligence_settings, update_runtime_settings, update_browser_settings, resolve_api_url, get_runtime_flags, set_browser_allow_all, workspace_onboarding_flag_exists, workspace_onboarding_flag_set, update_analytics_settings, get_analytics_settings, update_meet_settings, get_meet_settings, agent_server_status, reset_local_data, get_onboarding_completed, get_dictation_settings, update_dictation_settings, get_voice_server_settings, update_voice_server_settings, set_onboarding_completed}` — `schemas.rs`.
|
||||
|
||||
## Calls into
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use schema::{
|
||||
CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig,
|
||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
|
||||
LocalAiConfig, MatrixConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
|
||||
LocalAiConfig, MatrixConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
|
||||
MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
|
||||
|
||||
@@ -219,6 +219,11 @@ pub struct AnalyticsSettingsPatch {
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MeetSettingsPatch {
|
||||
pub auto_orchestrator_handoff: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocalAiSettingsPatch {
|
||||
pub runtime_enabled: Option<bool>,
|
||||
@@ -487,6 +492,33 @@ pub async fn load_and_apply_analytics_settings(
|
||||
apply_analytics_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Updates the Google Meet integration settings in the configuration.
|
||||
pub async fn apply_meet_settings(
|
||||
config: &mut Config,
|
||||
update: MeetSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
if let Some(enabled) = update.auto_orchestrator_handoff {
|
||||
config.meet.auto_orchestrator_handoff = enabled;
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
snapshot,
|
||||
vec![format!(
|
||||
"meet settings saved to {}",
|
||||
config.config_path.display()
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies meet settings updates, and saves it.
|
||||
pub async fn load_and_apply_meet_settings(
|
||||
update: MeetSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
apply_meet_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies browser settings updates, and saves it.
|
||||
pub async fn load_and_apply_browser_settings(
|
||||
update: BrowserSettingsPatch,
|
||||
|
||||
@@ -355,6 +355,48 @@ async fn apply_analytics_settings_updates_enabled() {
|
||||
assert!(!cfg.observability.analytics_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_meet_settings_updates_handoff_flag() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
// Default is OFF for a fresh config (issue #1299).
|
||||
assert!(
|
||||
!cfg.meet.auto_orchestrator_handoff,
|
||||
"fresh config must start with auto_orchestrator_handoff=false"
|
||||
);
|
||||
// Flip ON.
|
||||
let _ = apply_meet_settings(
|
||||
&mut cfg,
|
||||
MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: Some(true),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply on");
|
||||
assert!(cfg.meet.auto_orchestrator_handoff);
|
||||
// Flip OFF again — covers the off-after-on path.
|
||||
let _ = apply_meet_settings(
|
||||
&mut cfg,
|
||||
MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: Some(false),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply off");
|
||||
assert!(!cfg.meet.auto_orchestrator_handoff);
|
||||
// No-op patch must not change the flag.
|
||||
let prior = cfg.meet.auto_orchestrator_handoff;
|
||||
let _ = apply_meet_settings(
|
||||
&mut cfg,
|
||||
MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply noop");
|
||||
assert_eq!(prior, cfg.meet.auto_orchestrator_handoff);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_snapshot_wraps_snapshot_in_rpc_outcome() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Google Meet integration settings.
|
||||
//!
|
||||
//! Currently exposes a single privacy-relevant flag:
|
||||
//! `auto_orchestrator_handoff` — when `true`, ending a Google Meet call
|
||||
//! inside the OpenHuman webview hands the captured transcript to the
|
||||
//! orchestrator agent, which may **proactively** execute tools (e.g. post
|
||||
//! summaries to Slack, draft messages, schedule follow-ups). Default
|
||||
//! `false` so the user must opt in before any external action fires.
|
||||
//!
|
||||
//! See issue tinyhumansai/openhuman#1299.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MeetConfig {
|
||||
/// When `true`, the orchestrator agent receives the transcript of every
|
||||
/// completed Google Meet call as a fresh chat thread and is invited to
|
||||
/// take proactive actions on it (drafting messages, scheduling
|
||||
/// follow-ups, etc.). When `false` (the default), transcripts still
|
||||
/// land in memory but no auto-orchestrator handoff fires.
|
||||
#[serde(default = "default_auto_orchestrator_handoff")]
|
||||
pub auto_orchestrator_handoff: bool,
|
||||
}
|
||||
|
||||
fn default_auto_orchestrator_handoff() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl Default for MeetConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_orchestrator_handoff: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_disables_handoff() {
|
||||
let cfg = MeetConfig::default();
|
||||
assert!(
|
||||
!cfg.auto_orchestrator_handoff,
|
||||
"auto_orchestrator_handoff must default to false (privacy-conservative)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_helper_returns_false() {
|
||||
assert!(!default_auto_orchestrator_handoff());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_missing_optional_fields_uses_defaults() {
|
||||
let cfg: MeetConfig = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(
|
||||
!cfg.auto_orchestrator_handoff,
|
||||
"missing field must deserialize to false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_respects_explicit_handoff_flag() {
|
||||
let cfg: MeetConfig = serde_json::from_value(json!({
|
||||
"auto_orchestrator_handoff": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(cfg.auto_orchestrator_handoff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_handoff_flag() {
|
||||
let original = MeetConfig {
|
||||
auto_orchestrator_handoff: true,
|
||||
};
|
||||
let s = serde_json::to_string(&original).unwrap();
|
||||
let back: MeetConfig = serde_json::from_str(&s).unwrap();
|
||||
assert!(back.auto_orchestrator_handoff);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub use load::{
|
||||
user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID,
|
||||
};
|
||||
mod local_ai;
|
||||
mod meet;
|
||||
mod node;
|
||||
mod observability;
|
||||
mod proxy;
|
||||
@@ -45,6 +46,7 @@ pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
|
||||
pub use identity_cost::{CostConfig, ModelPricing};
|
||||
pub use learning::{LearningConfig, ReflectionSource};
|
||||
pub use local_ai::{LocalAiConfig, LocalAiUsage};
|
||||
pub use meet::MeetConfig;
|
||||
pub use node::NodeConfig;
|
||||
pub use observability::ObservabilityConfig;
|
||||
pub use proxy::{
|
||||
|
||||
@@ -157,6 +157,12 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub dictation: DictationConfig,
|
||||
|
||||
/// Google Meet integration settings — currently the
|
||||
/// `auto_orchestrator_handoff` privacy gate (see
|
||||
/// [`crate::openhuman::config::schema::MeetConfig`]).
|
||||
#[serde(default)]
|
||||
pub meet: MeetConfig,
|
||||
|
||||
/// Whether the user has completed the **React UI** onboarding flow.
|
||||
///
|
||||
/// Set by `OnboardingOverlay.tsx::handleDone` and the multi-step
|
||||
@@ -288,6 +294,7 @@ impl Default for Config {
|
||||
learning: LearningConfig::default(),
|
||||
update: UpdateConfig::default(),
|
||||
dictation: DictationConfig::default(),
|
||||
meet: MeetConfig::default(),
|
||||
onboarding_completed: false,
|
||||
chat_onboarding_completed: false,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ struct AnalyticsSettingsUpdate {
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MeetSettingsUpdate {
|
||||
auto_orchestrator_handoff: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalAiSettingsUpdate {
|
||||
runtime_enabled: Option<bool>,
|
||||
@@ -125,6 +130,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("workspace_onboarding_flag_set"),
|
||||
schemas("update_analytics_settings"),
|
||||
schemas("get_analytics_settings"),
|
||||
schemas("update_meet_settings"),
|
||||
schemas("get_meet_settings"),
|
||||
schemas("agent_server_status"),
|
||||
schemas("reset_local_data"),
|
||||
schemas("get_onboarding_completed"),
|
||||
@@ -198,6 +205,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("get_analytics_settings"),
|
||||
handler: handle_get_analytics_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_meet_settings"),
|
||||
handler: handle_update_meet_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_meet_settings"),
|
||||
handler: handle_get_meet_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("agent_server_status"),
|
||||
handler: handle_agent_server_status,
|
||||
@@ -507,6 +522,29 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"update_meet_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_meet_settings",
|
||||
description:
|
||||
"Update Google Meet integration settings (currently the auto-orchestrator-handoff privacy gate).",
|
||||
inputs: vec![optional_bool(
|
||||
"auto_orchestrator_handoff",
|
||||
"When true, ending a Meet call hands the transcript to the orchestrator for proactive follow-up actions.",
|
||||
)],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"get_meet_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_meet_settings",
|
||||
description: "Read current Google Meet integration settings.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "auto_orchestrator_handoff",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether the orchestrator handoff fires on Meet call end.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"agent_server_status" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "agent_server_status",
|
||||
@@ -796,6 +834,60 @@ fn handle_get_analytics_settings(_params: Map<String, Value>) -> ControllerFutur
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_meet_settings enter");
|
||||
let update = match deserialize_params::<MeetSettingsUpdate>(params) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_meet_settings invalid params: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
log::debug!(
|
||||
"[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?}",
|
||||
update.auto_orchestrator_handoff
|
||||
);
|
||||
let patch = config_rpc::MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: update.auto_orchestrator_handoff,
|
||||
};
|
||||
match config_rpc::load_and_apply_meet_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] update_meet_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_meet_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
log::debug!("[config][rpc] get_meet_settings enter");
|
||||
let config = match config_rpc::load_config_with_timeout().await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] get_meet_settings load failed: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let auto_orchestrator_handoff = config.meet.auto_orchestrator_handoff;
|
||||
log::debug!(
|
||||
"[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff}"
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"auto_orchestrator_handoff": auto_orchestrator_handoff,
|
||||
});
|
||||
to_json(RpcOutcome::new(
|
||||
result,
|
||||
vec!["meet settings read".to_string()],
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_agent_server_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::agent_server_status()) })
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() {
|
||||
"workspace_onboarding_flag_set",
|
||||
"update_analytics_settings",
|
||||
"get_analytics_settings",
|
||||
"update_meet_settings",
|
||||
"get_meet_settings",
|
||||
"agent_server_status",
|
||||
"reset_local_data",
|
||||
"get_onboarding_completed",
|
||||
|
||||
@@ -1835,6 +1835,16 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() {
|
||||
Some(true),
|
||||
"expected chatOnboardingCompleted=true from test config: {body}"
|
||||
);
|
||||
// #1299 — Meet auto-orchestrator handoff is the privacy gate that
|
||||
// controls whether ending a Meet call hands the transcript to the
|
||||
// orchestrator agent. Default is OFF on a fresh config so meeting
|
||||
// notes never auto-broadcast to Slack #general etc. without consent.
|
||||
assert_eq!(
|
||||
body.get("meetAutoOrchestratorHandoff")
|
||||
.and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"expected meetAutoOrchestratorHandoff=false default: {body}"
|
||||
);
|
||||
|
||||
let runtime = body.get("runtime").expect("expected runtime object");
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user