Skip to content
Merged

prod #378

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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@d-id/client-sdk",
"private": false,
"version": "1.1.61",
"version": "1.1.63",
"type": "module",
"description": "d-id client sdk",
"repository": {
Expand Down
11 changes: 4 additions & 7 deletions src/auth/get-auth-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,15 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.generated-external-id_mocked-random-id');
expect(result).toBe('Client-Key test-client-key.generated-external-id');
});

it('should use provided externalId in Client-Key header', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const externalId = 'user-123';
const result = getAuthHeader(auth, externalId);

expect(result).toBe('Client-Key test-client-key.user-123_mocked-random-id');
expect(result).toContain('user-123');
expect(result).toBe('Client-Key test-client-key.user-123');
});

it('should use externalId from localStorage when not provided', () => {
Expand All @@ -147,8 +146,7 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.stored-user-id_mocked-random-id');
expect(result).toContain('stored-user-id');
expect(result).toBe('Client-Key test-client-key.stored-user-id');
});

it('should generate new externalId and store it when localStorage is empty', () => {
Expand All @@ -158,8 +156,7 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.new-generated-id_mocked-random-id');
expect(result).toContain('new-generated-id');
expect(result).toBe('Client-Key test-client-key.new-generated-id');
expect(window.localStorage.getItem('did_external_key_id')).toBe(mockRandomId);
});

Expand Down
3 changes: 1 addition & 2 deletions src/auth/get-auth-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ export function getExternalId(externalId?: string): string {
return key;
}

let sessionKey = getRandom();
export function getAuthHeader(auth: Auth, externalId?: string) {
if (auth.type === 'bearer') {
return `Bearer ${auth.token}`;
} else if (auth.type === 'basic') {
return `Basic ${'token' in auth ? auth.token : btoa(`${auth.username}:${auth.password}`)}`;
} else if (auth.type === 'key') {
return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}_${sessionKey}`;
return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}`;
} else {
throw new Error(`Unknown auth type: ${auth}`);
}
Expand Down
76 changes: 76 additions & 0 deletions src/services/agent-manager/connect-to-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe('connect-to-manager', () => {
onSrcObjectReady: jest.fn(),
onNewMessage: jest.fn(),
onNewChat: jest.fn(),
onToolEvent: jest.fn(),
},
};

Expand Down Expand Up @@ -239,6 +240,7 @@ describe('connect-to-manager', () => {
let onAgentActivityStateChange: (state: AgentActivityState) => void;
let onFirstAudioDetected: ((metrics: { latency?: number; networkLatency?: number }) => void) | undefined;
let onStreamReady: (() => void) | undefined;
let onToolEvent: ((event: StreamEvents, data: any) => void) | undefined;

beforeEach(async () => {
// Initialize callbacks to avoid undefined errors
Expand All @@ -252,6 +254,7 @@ describe('connect-to-manager', () => {
onAgentActivityStateChange = options.callbacks.onAgentActivityStateChange;
onFirstAudioDetected = options.callbacks.onFirstAudioDetected;
onStreamReady = options.callbacks.onStreamReady;
onToolEvent = options.callbacks.onToolEvent;

return new Promise(resolve => {
setTimeout(() => {
Expand Down Expand Up @@ -447,6 +450,79 @@ describe('connect-to-manager', () => {
});
});
});

describe('onToolEvent', () => {
const startedPayload = {
call_id: 'call-1',
name: 'lookup',
input: { q: 'hello' },
output: {},
timestamp: '2026-04-28T00:00:00Z',
};
const donePayload = {
...startedPayload,
output: { result: 'ok' },
duration_ms: 123,
extra: { region: 'eu' },
};
const errorPayload = {
...donePayload,
extra: { reason: 'timeout', code: 504 },
};

beforeEach(() => {
(mockAnalytics.track as jest.Mock).mockClear();
});

it('forwards started event to user callback and tracks agent-tool-call', () => {
onToolEvent?.(StreamEvents.ToolCallStarted, startedPayload as any);

expect(mockOptions.callbacks.onToolEvent).toHaveBeenCalledWith(
StreamEvents.ToolCallStarted,
startedPayload
);
expect(mockAnalytics.track).toHaveBeenCalledWith('agent-tool-call', {
event: 'started',
call_id: 'call-1',
name: 'lookup',
});
});

it('tracks done event with duration_ms and extra_keys count', () => {
onToolEvent?.(StreamEvents.ToolCallDone, donePayload as any);

expect(mockAnalytics.track).toHaveBeenCalledWith('agent-tool-call', {
event: 'done',
call_id: 'call-1',
name: 'lookup',
duration_ms: 123,
extra_keys: 1,
});
});

it('tracks error event with duration_ms and extra_keys count', () => {
onToolEvent?.(StreamEvents.ToolCallError, errorPayload as any);

expect(mockAnalytics.track).toHaveBeenCalledWith('agent-tool-call', {
event: 'error',
call_id: 'call-1',
name: 'lookup',
duration_ms: 123,
extra_keys: 2,
});
});

it('handles missing extra map by emitting extra_keys=0', () => {
const { extra: _extra, ...donePayloadWithoutExtra } = donePayload;

onToolEvent?.(StreamEvents.ToolCallDone, donePayloadWithoutExtra as any);

expect(mockAnalytics.track).toHaveBeenCalledWith(
'agent-tool-call',
expect.objectContaining({ extra_keys: 0 })
);
});
});
});

describe('Stream Options Mapping', () => {
Expand Down
31 changes: 31 additions & 0 deletions src/services/agent-manager/connect-to-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
StreamEvents,
StreamType,
StreamingState,
ToolCallDonePayload,
ToolCallErrorPayload,
ToolEventPayload,
TransportProvider,
} from '@sdk/types';
import { isStreamsV2Agent } from '@sdk/utils/agent';
Expand Down Expand Up @@ -161,6 +164,30 @@ function trackLegacyVideoAnalytics(
}
}

function trackToolEventAnalytics(
event: StreamEvents.ToolCallStarted | StreamEvents.ToolCallDone | StreamEvents.ToolCallError,
payload: ToolEventPayload,
analytics: Analytics
) {
const baseProps: Record<string, unknown> = {
call_id: payload.call_id,
name: payload.name,
};

if (event === StreamEvents.ToolCallStarted) {
analytics.track('agent-tool-call', { ...baseProps, event: 'started' });
return;
}

const finishedPayload = payload as ToolCallDonePayload | ToolCallErrorPayload;
analytics.track('agent-tool-call', {
...baseProps,
event: event === StreamEvents.ToolCallDone ? 'done' : 'error',
duration_ms: finishedPayload.duration_ms,
extra_keys: finishedPayload.extra ? Object.keys(finishedPayload.extra).length : 0,
});
}

type ConnectToManagerOptions = AgentManagerOptions & {
callbacks: AgentManagerOptions['callbacks'] & {
onVideoIdChange?: (videoId: string | null) => void;
Expand Down Expand Up @@ -263,6 +290,10 @@ function connectToManager(
const readyLatency = streamReadyTimestampTracker.get(true);
analytics.track('agent-chat', { event: 'ready', latency: readyLatency });
},
onToolEvent: ((event: any, data: any) => {
options.callbacks.onToolEvent?.(event, data);
trackToolEventAnalytics(event, data, analytics);
}) as typeof options.callbacks.onToolEvent,
},
},
signal
Expand Down
46 changes: 33 additions & 13 deletions src/services/agent-manager/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { Agent, AgentManager, AgentManagerOptions, ChatMode, ConnectionState, St
import { initializeAnalytics } from '../analytics/mixpanel';
import { createChat } from '../chat';
import { getInitialMessages } from '../chat/intial-messages';
import { sendInterrupt, validateInterrupt } from '../interrupt';
import { createSocketManager } from '../socket-manager';
import { createMessageEventQueue } from '../socket-manager/message-queue';
import { initializeStreamAndChat } from './connect-to-manager';
Expand All @@ -26,7 +25,6 @@ jest.mock('./connect-to-manager');
jest.mock('../socket-manager/message-queue');
jest.mock('../chat/intial-messages');
jest.mock('../chat');
jest.mock('../interrupt');
jest.mock('../../utils/retry-operation', () => ({ retryOperation: jest.fn(fn => fn()) }));
jest.mock('../../utils', () => ({ getRandom: jest.fn(() => 'random-id-123') }));
jest.mock('../../utils/chat', () => ({
Expand Down Expand Up @@ -82,8 +80,6 @@ describe('createAgentManager', () => {
(createMessageEventQueue as jest.Mock).mockReturnValue({ onMessage: jest.fn(), clearQueue: jest.fn() });
(getInitialMessages as jest.Mock).mockReturnValue([]);
(createChat as jest.Mock).mockResolvedValue({ chat: mockChat });
(validateInterrupt as jest.Mock).mockReturnValue(undefined);
(sendInterrupt as jest.Mock).mockReturnValue(undefined);
});

describe('createAgentManager', () => {
Expand Down Expand Up @@ -556,8 +552,7 @@ describe('createAgentManager', () => {

manager.interrupt({ type: 'click' });

expect(validateInterrupt).toHaveBeenCalledWith(mockStreamingManager, StreamType.Legacy, null);
expect(sendInterrupt).toHaveBeenCalledWith(mockStreamingManager, null);
expect(mockStreamingManager.interrupt).toHaveBeenCalledWith('click');
expect(mockAnalytics.track).toHaveBeenCalledWith('agent-video-interrupt', {
type: 'click',
video_duration_to_interrupt: expect.any(Number),
Expand All @@ -569,18 +564,15 @@ describe('createAgentManager', () => {
// Add a message to interrupt
await manager.chat('Hello');

// Mock validateInterrupt to throw an error
(validateInterrupt as jest.Mock).mockImplementationOnce(() => {
// Mock streamingManager.interrupt to throw a validation error
(mockStreamingManager.interrupt as jest.Mock).mockImplementationOnce(() => {
throw new Error('Interrupt validation failed');
});

expect(() => manager.interrupt({ type: 'click' })).toThrow('Interrupt validation failed');

// Verify validateInterrupt was called
expect(validateInterrupt).toHaveBeenCalledWith(mockStreamingManager, StreamType.Legacy, null);

// Verify sendInterrupt was not called due to validation failure
expect(sendInterrupt).not.toHaveBeenCalled();
// Verify streamingManager.interrupt was called
expect(mockStreamingManager.interrupt).toHaveBeenCalledWith('click');
});
});

Expand Down Expand Up @@ -785,6 +777,34 @@ describe('createAgentManager', () => {
});
});

describe('replaceMicrophoneTrack', () => {
let manager: AgentManager;

beforeEach(async () => {
manager = await createAgentManager('agent-123', mockOptions);
await manager.connect();
});

it('should replace microphone track when available', async () => {
const mockTrack = { kind: 'audio', id: 'audio-track-1' } as unknown as MediaStreamTrack;
const mockReplace = jest.fn().mockResolvedValue(undefined);
mockStreamingManager.replaceMicrophoneTrack = mockReplace;

await manager.replaceMicrophoneTrack?.(mockTrack);

expect(mockReplace).toHaveBeenCalledWith(mockTrack);
});

it('should throw error when replaceMicrophoneTrack is not available', async () => {
mockStreamingManager.replaceMicrophoneTrack = undefined;
const mockTrack = { kind: 'audio', id: 'audio-track-1' } as unknown as MediaStreamTrack;

await expect(manager.replaceMicrophoneTrack?.(mockTrack)).rejects.toThrow(
'replaceMicrophoneTrack is not available for this streaming manager'
);
});
});

describe('publishCameraStream', () => {
let manager: AgentManager;

Expand Down
31 changes: 15 additions & 16 deletions src/services/agent-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ChatResponse,
ClientToolHandler,
ConnectionState,
CreateSessionV2Options,
CreateStreamOptions,
Interrupt,
Message,
Expand All @@ -30,7 +29,6 @@ import { initializeAnalytics } from '../analytics/mixpanel';
import { interruptTimestampTracker, latencyTimestampTracker } from '../analytics/timestamp-tracker';
import { createChat, getRequestHeaders } from '../chat';
import { getInitialMessages } from '../chat/intial-messages';
import { sendInterrupt, sendInterruptV2, validateInterrupt } from '../interrupt';
import { SocketManager, createSocketManager } from '../socket-manager';
import { createMessageEventQueue } from '../socket-manager/message-queue';
import { StreamingManager } from '../streaming-manager';
Expand Down Expand Up @@ -122,12 +120,7 @@ export async function createAgentManager(agent: string, options: AgentManagerOpt
lastMessage.interrupted = true;
options.callbacks.onNewMessage?.([...items.messages], 'answer');

if (isStreamsV2) {
sendInterruptV2(items.streamingManager! as StreamingManager<CreateSessionV2Options>);
} else {
validateInterrupt(items.streamingManager, items.streamingManager?.streamType, videoId);
sendInterrupt(items.streamingManager!, videoId!);
}
items.streamingManager.interrupt(type);
};

const clientToolHandlers = new Map<string, ClientToolHandler>();
Expand Down Expand Up @@ -320,27 +313,33 @@ export async function createAgentManager(agent: string, options: AgentManagerOpt
mode: items.chatMode,
});
},
async publishMicrophoneStream(stream: MediaStream) {
publishMicrophoneStream(stream: MediaStream): Promise<void> {
if (!items.streamingManager?.publishMicrophoneStream) {
throw new Error('publishMicrophoneStream is not available for this streaming manager');
return Promise.reject(new Error('publishMicrophoneStream is not available for this streaming manager'));
}
return items.streamingManager.publishMicrophoneStream(stream);
},
async unpublishMicrophoneStream() {
unpublishMicrophoneStream(): Promise<void> {
if (!items.streamingManager?.unpublishMicrophoneStream) {
return;
return Promise.resolve();
}
return items.streamingManager.unpublishMicrophoneStream();
},
async publishCameraStream(stream: MediaStream) {
replaceMicrophoneTrack(track: MediaStreamTrack): Promise<void> {
if (!items.streamingManager?.replaceMicrophoneTrack) {
return Promise.reject(new Error('replaceMicrophoneTrack is not available for this streaming manager'));
}
return items.streamingManager.replaceMicrophoneTrack(track);
},
publishCameraStream(stream: MediaStream): Promise<void> {
if (!items.streamingManager?.publishCameraStream) {
throw new Error('publishCameraStream is not available for this streaming manager');
return Promise.reject(new Error('publishCameraStream is not available for this streaming manager'));
}
return items.streamingManager.publishCameraStream(stream);
},
async unpublishCameraStream() {
unpublishCameraStream(): Promise<void> {
if (!items.streamingManager?.unpublishCameraStream) {
return;
return Promise.resolve();
}
return items.streamingManager.unpublishCameraStream();
},
Expand Down
Loading
Loading