Merge pull request #169 from M3gA-Mind/feat/dmg-cyrus

Fixing Notion,Gmail and Syncing
This commit is contained in:
Cyrus Gray
2026-03-12 14:53:06 +05:30
committed by GitHub
14 changed files with 285 additions and 3067 deletions
+173 -2456
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@
"android:build": "tauri android build",
"test": "vitest run",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:watch": "vi13:17:20.609 ERROR [skill:gmail] start() failed: start() timed out after 30s\ntest",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml",
+1 -1
Submodule skills updated: 88c9c8c8d1...e6dd1730eb
+1
View File
@@ -14,3 +14,4 @@
/tdlib-build/
# TDLib downloaded by scripts/download-tdlib.sh (avoids build timeout)
/tdlib-cache/
/tdlib-prebuilt
+1 -1
View File
@@ -338,7 +338,7 @@ async fn spawn_state_writer(
_ = interval.tick() => {
event_count += 1;
if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s)
log::info!("[alphahuman] Health monitoring active (event #{})", event_count);
// log::info!("[alphahuman] Health monitoring active (event #{})", event_count);
}
},
_ = cancel.cancelled() => {
+4 -4
View File
@@ -52,7 +52,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
loginUrl: `${BACKEND_URL}/auth/google/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/google/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'github',
@@ -61,7 +61,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-gray-900 border border-gray-800',
hoverColor: 'hover:bg-gray-800 hover:border-gray-700',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/github/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/github/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'twitter',
@@ -70,7 +70,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-black border border-gray-800',
hoverColor: 'hover:bg-gray-900 hover:border-gray-700',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/twitter/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/twitter/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'discord',
@@ -79,7 +79,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-indigo-600 border border-indigo-500',
hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/discord/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/discord/login?${IS_DEV ? 'responseType=json' : ''}`,
},
];
+50 -4
View File
@@ -1,7 +1,10 @@
/**
* Send Notion user metadata to the backend via the
* Send Notion metadata to the backend via the
* `integration:metadata-sync` socket event so the server can merge it
* into the user's Notion OAuth integration metadata.
*
* Mirrors the Gmail metadata sync pattern: we send the primary profile plus
* additional Notion data (pages, summaries) when available.
*/
import { emitViaRustSocket } from '../../../utils/tauriSocket';
@@ -16,14 +19,47 @@ export interface NotionUserProfileLike {
avatar_url?: string | null;
}
export interface NotionPageSummaryLike {
id: string;
title: string;
url: string | null;
last_edited_time: string;
content_text: string | null;
}
export interface NotionSummaryLike {
id: number;
pageId: string;
url: string | null;
summary: string;
category: string | null;
sentiment: string;
topics: string[];
sourceCreatedAt: string;
sourceUpdatedAt: string;
}
/**
* Emit `integration:metadata-sync` with Notion user profile so the
* backend can merge it into the user's Notion OAuth integration.
* Shape of Notion data we care about for backend integration metadata sync.
* This is populated from the Notion Redux slice in the runner.
*/
export interface NotionStateForSync {
profile?: NotionUserProfileLike | null;
pages?: NotionPageSummaryLike[] | null;
summaries?: NotionSummaryLike[] | null;
}
/**
* Emit `integration:metadata-sync` with Notion profile plus additional
* Notion data (pages and summaries) so the backend can merge everything
* into the user's Notion OAuth integration metadata.
*
* No-op when profile is missing or invalid.
*/
export function syncNotionMetadataToBackend(
profile: NotionUserProfileLike | null | undefined
notionState: NotionStateForSync | null | undefined
): void {
const profile = notionState?.profile;
if (!profile || !profile.id) return;
const metadata: Record<string, unknown> = {
@@ -34,6 +70,16 @@ export function syncNotionMetadataToBackend(
avatar_url: profile.avatar_url ?? null,
};
if (Array.isArray(notionState.pages) && notionState.pages.length > 0) {
metadata.pages = notionState.pages;
metadata.pages_total = notionState.pages.length;
}
if (Array.isArray(notionState.summaries) && notionState.summaries.length > 0) {
metadata.summaries = notionState.summaries;
metadata.summaries_total = notionState.summaries.length;
}
const payload = { requestId: crypto.randomUUID(), provider: PROVIDER_NOTION, metadata };
void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload);
-1
View File
@@ -6,7 +6,6 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { clearToolsCache } from '../ai/tools/loader';
import { forceToolsCacheRefresh } from './file-watcher';
// Prevent excessive updates - limit to once per 10 seconds
+2 -2
View File
@@ -99,10 +99,10 @@ function simpleHash(str: string): number {
/**
* Force a cache refresh (useful for manual triggers)
*/
export function forceToolsCacheRefresh(): Promise<void> {
export async function forceToolsCacheRefresh(): Promise<void> {
console.log('🔄 Forcing tools cache refresh...');
clearToolsCache();
clearAICache();
lastModifiedTime = null; // Reset to trigger next check
return loadAIConfig();
await loadAIConfig();
}
+1
View File
@@ -447,6 +447,7 @@ const Conversations = () => {
toolArgs
);
const result = await skillManager.callTool(skillId, toolName, toolArgs);
console.log(`[Conversations] tool "${toolName}" calling result:`, result);
toolResultContent = result.content.map(c => c.text).join('\n');
let toolReturnedError = result.isError;
if (!toolReturnedError && toolResultContent) {
+23 -42
View File
@@ -12,7 +12,10 @@ import {
type GmailStateForSync,
syncGmailMetadataToBackend,
} from '../lib/gmail/services/metadataSync';
import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync';
import {
type NotionStateForSync,
syncNotionMetadataToBackend,
} from '../lib/notion/services/metadataSync';
import { skillManager } from '../lib/skills/manager';
import type { SkillManifest } from '../lib/skills/types';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
@@ -106,41 +109,33 @@ function syncGmailStateToSlice(
syncGmailMetadataToBackend(gmailState as GmailStateForSync);
}
/** Sync pages and summaries from notion skill state into notionSlice. */
/** Sync profile, pages, and summaries from notion skill state into notionSlice and backend metadata. */
function syncNotionStateToSlice(
notionState: Record<string, unknown> | undefined,
dispatch: ReturnType<typeof useAppDispatch>
): void {
if (!notionState || typeof notionState !== 'object') return;
if (Array.isArray(notionState.pages)) {
dispatch(setNotionPages(notionState.pages as NotionPageSummary[]));
}
if (Array.isArray(notionState.summaries)) {
dispatch(setNotionSummaries(notionState.summaries as NotionSummary[]));
}
}
const profile =
notionState.profile !== undefined && notionState.profile != null
? (notionState.profile as NotionUserProfile)
: null;
const pages = Array.isArray(notionState.pages) ? (notionState.pages as NotionPageSummary[]) : [];
const summaries = Array.isArray(notionState.summaries)
? (notionState.summaries as NotionSummary[])
: [];
async function syncNotionUserOnConnect(dispatch: ReturnType<typeof useAppDispatch>): Promise<void> {
try {
const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' });
if (!toolResult || toolResult.isError || toolResult.content.length === 0) {
return;
}
const first = toolResult.content[0];
const raw = first?.text;
if (!raw) return;
// Update profile in notionSlice if present
dispatch(setNotionProfile(profile));
const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string };
if ('error' in parsed && parsed.error) {
return;
}
const profile = parsed as NotionUserProfile;
dispatch(setNotionProfile(profile));
syncNotionMetadataToBackend(profile);
} catch (e) {
console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e);
if (pages.length > 0) {
dispatch(setNotionPages(pages));
}
if (summaries.length > 0) {
dispatch(setNotionSummaries(summaries));
}
const stateForSync: NotionStateForSync = { profile, pages, summaries };
syncNotionMetadataToBackend(stateForSync);
}
export default function SkillProvider({ children }: { children: ReactNode }) {
@@ -149,7 +144,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
const skillStates = useAppSelector(state => state.skills.skillStates);
const dispatch = useAppDispatch();
const initRef = useRef(false);
const lastNotionConnectionStatusRef = useRef<string | undefined>(undefined);
// Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration)
const gmailSkillState = skillStates?.gmail as Record<string, unknown> | undefined;
@@ -165,19 +159,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
syncNotionStateToSlice(notionSkillState, dispatch);
}, [notionSkillState, dispatch]);
// When Notion connection_status transitions to "connected", fetch the current user
// via the notion get-user tool, store it in notionSlice, and sync metadata to backend.
useEffect(() => {
if (!notionSkillState || typeof notionSkillState !== 'object') return;
const connectionStatus = notionSkillState.connection_status as string | undefined;
const prev = lastNotionConnectionStatusRef.current;
lastNotionConnectionStatusRef.current = connectionStatus;
if (connectionStatus === 'connected' && prev !== 'connected') {
void syncNotionUserOnConnect(dispatch);
}
}, [notionSkillState, dispatch]);
// Listen for skill state changes emitted from the Rust runtime event loop
useEffect(() => {
let unlisten: (() => void) | undefined;
-536
View File
@@ -1,536 +0,0 @@
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import type {
AgentExecutionOptions,
AgentExecutionResult,
AgentToolExecution,
AgentToolSchema,
} from '../../types/agent';
import { AgentLoopService } from '../agentLoop';
import { AgentToolRegistry } from '../agentToolRegistry';
import { apiClient } from '../apiClient';
// Mock dependencies
vi.mock('../agentToolRegistry');
vi.mock('../apiClient');
describe('AgentLoopService', () => {
let service: AgentLoopService;
const mockToolRegistry = AgentToolRegistry as vi.MockedClass<typeof AgentToolRegistry>;
const mockApiClient = apiClient as { post: Mock };
const mockToolSchemas: AgentToolSchema[] = [
{
type: 'function',
function: {
name: 'github_list_issues',
description: 'List GitHub issues for a repository',
parameters: {
type: 'object',
properties: {
owner: { type: 'string', description: 'Repository owner' },
repo: { type: 'string', description: 'Repository name' },
},
required: ['owner', 'repo'],
},
},
},
{
type: 'function',
function: {
name: 'notion_create_page',
description: 'Create a new Notion page',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Page title' },
content: { type: 'string', description: 'Page content' },
},
required: ['title'],
},
},
},
];
beforeEach(() => {
service = AgentLoopService.getInstance();
vi.clearAllMocks();
// Setup default mock implementations
const mockRegistryInstance = { loadToolSchemas: vi.fn(), executeTool: vi.fn() };
mockToolRegistry.getInstance.mockReturnValue(mockRegistryInstance as any);
mockRegistryInstance.loadToolSchemas.mockResolvedValue(mockToolSchemas);
});
describe('executeTask', () => {
test('should execute simple task without tool calls', async () => {
const mockResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'Hello! How can I help you today?',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 },
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const result = await service.executeTask('Hello', 'conv_123', {
maxIterations: 5,
timeoutMs: 30000,
});
expect(result.status).toBe('completed');
expect(result.finalResponse).toBe('Hello! How can I help you today?');
expect(result.iterations).toBe(1);
expect(result.toolExecutions).toHaveLength(0);
expect(result.executionTime).toBeGreaterThan(0);
// Verify API call format
expect(mockApiClient.post).toHaveBeenCalledWith(
'/api/v1/conversations/conv_123/messages',
expect.objectContaining({
model: expect.any(String),
messages: expect.arrayContaining([
expect.objectContaining({ role: 'user', content: 'Hello' }),
]),
tools: mockToolSchemas,
tool_choice: 'auto',
})
);
});
test('should execute task with single tool call', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_123',
type: 'function' as const,
function: {
name: 'github_list_issues',
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I found 3 open issues in your repository.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
usage: { prompt_tokens: 50, completion_tokens: 20, total_tokens: 70 },
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_123',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 1500,
endTime: Date.now(),
executionTimeMs: 1500,
result: '{"issues":[{"title":"Bug fix","number":1}]}',
};
// Setup mocks
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
const result = await service.executeTask('Show me GitHub issues', 'conv_123');
expect(result.status).toBe('completed');
expect(result.finalResponse).toBe('I found 3 open issues in your repository.');
expect(result.iterations).toBe(2);
expect(result.toolExecutions).toHaveLength(1);
expect(result.toolExecutions[0].toolName).toBe('list_issues');
expect(result.toolExecutions[0].status).toBe('success');
// Verify tool execution was called with correct parameters
expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith(
'github',
'list_issues',
'{"owner":"user","repo":"test"}'
);
});
test('should handle multiple tool calls in sequence', async () => {
const mockFirstToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: {
name: 'github_list_issues',
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockSecondToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_2',
type: 'function' as const,
function: { name: 'notion_create_page', arguments: '{"title":"Issues Summary"}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I created a summary page with your GitHub issues.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution1: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 2000,
endTime: Date.now() - 1000,
executionTimeMs: 1000,
result: '{"issues":[{"title":"Bug fix","number":1}]}',
};
const mockToolExecution2: AgentToolExecution = {
id: 'exec_2',
toolName: 'create_page',
skillId: 'notion',
arguments: '{"title":"Issues Summary"}',
status: 'success',
startTime: Date.now() - 800,
endTime: Date.now(),
executionTimeMs: 800,
result: '{"page_id":"page_123"}',
};
// Setup mocks
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool
.mockResolvedValueOnce(mockToolExecution1)
.mockResolvedValueOnce(mockToolExecution2);
mockApiClient.post
.mockResolvedValueOnce({ data: mockFirstToolCallResponse })
.mockResolvedValueOnce({ data: mockSecondToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
const result = await service.executeTask(
'Get GitHub issues and create a summary page',
'conv_123',
{ maxIterations: 5 }
);
expect(result.status).toBe('completed');
expect(result.iterations).toBe(3);
expect(result.toolExecutions).toHaveLength(2);
expect(result.toolExecutions[0].skillId).toBe('github');
expect(result.toolExecutions[1].skillId).toBe('notion');
});
test('should handle tool execution timeout', async () => {
const result = await service.executeTask(
'Test timeout',
'conv_123',
{ maxIterations: 1, timeoutMs: 100 } // Very short timeout
);
// The timeout logic depends on how it's implemented in the actual service
// This test may need adjustment based on the actual implementation
expect(result.status).toBe('timeout');
expect(result.error).toContain('timeout');
});
test('should respect maximum iterations limit', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: { name: 'github_list_issues', arguments: '{}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
// Mock to always return tool calls (infinite loop scenario)
mockApiClient.post.mockResolvedValue({ data: mockToolCallResponse });
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{}',
status: 'success',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
result: '{}',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
const result = await service.executeTask('Infinite loop test', 'conv_123', {
maxIterations: 2,
timeoutMs: 10000,
});
expect(result.status).toBe('max_iterations');
expect(result.iterations).toBe(2);
expect(result.error).toContain('maximum iterations');
});
test('should handle tool execution error gracefully', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: { name: 'invalid_tool', arguments: '{}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockErrorResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I encountered an error while executing the tool.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'invalid_tool',
skillId: 'unknown',
arguments: '{}',
status: 'error',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
errorMessage: 'Tool not found',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockErrorResponse });
const result = await service.executeTask('Test error handling', 'conv_123');
expect(result.status).toBe('completed');
expect(result.toolExecutions).toHaveLength(1);
expect(result.toolExecutions[0].status).toBe('error');
expect(result.toolExecutions[0].errorMessage).toBe('Tool not found');
});
test('should handle API client errors', async () => {
mockApiClient.post.mockRejectedValue(new Error('Network error'));
const result = await service.executeTask('Test API error', 'conv_123');
expect(result.status).toBe('error');
expect(result.error).toContain('Network error');
expect(result.iterations).toBe(0);
expect(result.toolExecutions).toHaveLength(0);
});
test('should parse tool name from function name correctly', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: {
name: 'github_list_issues', // Should parse to skillId=github, toolName=list_issues
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Done', tool_calls: undefined },
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
result: '{}',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
await service.executeTask('Test tool parsing', 'conv_123');
// Verify correct parsing of skill ID and tool name
expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith(
'github',
'list_issues',
'{"owner":"user","repo":"test"}'
);
});
});
describe('singleton behavior', () => {
test('should return the same instance', () => {
const instance1 = AgentLoopService.getInstance();
const instance2 = AgentLoopService.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('task execution options', () => {
test('should use default options when none provided', async () => {
const mockResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Test response' },
finish_reason: 'stop' as const,
},
],
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const result = await service.executeTask('Test', 'conv_123');
// Should complete successfully with defaults
expect(result.status).toBe('completed');
expect(result.executionTime).toBeGreaterThan(0);
});
test('should respect custom execution options', async () => {
const mockResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Test response' },
finish_reason: 'stop' as const,
},
],
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const customOptions: AgentExecutionOptions = {
maxIterations: 3,
timeoutMs: 5000,
model: 'gpt-3.5-turbo',
temperature: 0.7,
};
const result = await service.executeTask('Test', 'conv_123', customOptions);
expect(result.status).toBe('completed');
// Verify custom options were passed to API
expect(mockApiClient.post).toHaveBeenCalledWith(
'/api/v1/conversations/conv_123/messages',
expect.objectContaining({ model: 'gpt-3.5-turbo', temperature: 0.7 })
);
});
});
});
+7 -3
View File
@@ -4,7 +4,6 @@ import { injectAll } from '../lib/ai/injector';
import type { Message } from '../lib/ai/providers/interface';
import { threadApi } from '../services/api/threadApi';
import type { Thread, ThreadMessage } from '../types/thread';
import type { RootState } from './index';
interface ThreadState {
// Existing local data (will be persisted)
@@ -237,8 +236,13 @@ const threadSlice = createSlice({
: lastUserMessage;
persistedMessages.push(stableMessage);
// Keep state.messages in sync with the stable id
const idx = state.messages.findLastIndex(m => m.id === lastUserMessage.id);
if (idx !== -1) state.messages[idx] = stableMessage;
const messages = state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].id === lastUserMessage.id) {
messages[i] = stableMessage;
break;
}
}
}
}
+21 -16
View File
@@ -1,17 +1,13 @@
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import {invoke, isTauri as coreIsTauri} from '@tauri-apps/api/core';
import {getCurrent, onOpenUrl} from '@tauri-apps/plugin-deep-link';
import { skillManager } from '../lib/skills/manager';
import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { store } from '../store';
import { setToken } from '../store/authSlice';
import { setSkillState } from '../store/skillsSlice';
import {
decryptIntegrationTokens,
hexToBase64,
type IntegrationTokensPayload,
} from './integrationTokensCrypto';
import {skillManager} from '../lib/skills/manager';
import {consumeLoginToken, fetchIntegrationTokens} from '../services/api/authApi';
import {buildManualSentryEvent, enqueueError} from '../services/errorReportQueue';
import {store} from '../store';
import {setToken} from '../store/authSlice';
import {setSkillState} from '../store/skillsSlice';
import {decryptIntegrationTokens, hexToBase64, type IntegrationTokensPayload,} from './integrationTokensCrypto';
function getCurrentUserId(): string | null {
const state = store.getState();
@@ -40,11 +36,13 @@ function getCurrentUserId(): string | null {
*/
const handleAuthDeepLink = async (parsed: URL) => {
const token = parsed.searchParams.get('token');
const key = parsed.searchParams.get('key');
if (!token) {
console.warn('[DeepLink] URL did not contain a token query parameter');
return;
}
console.log('[DeepLink] Received auth token');
try {
@@ -53,9 +51,16 @@ const handleAuthDeepLink = async (parsed: URL) => {
console.warn('[DeepLink] Failed to show window:', err);
}
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
window.location.hash = '/onboarding';
if (key === 'auth') {
store.dispatch(setToken(token));
window.location.hash = '/onboarding';
} else {
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
window.location.hash = '/onboarding';
}
};
/**