@agent-native/toolkit 0.221.0-same-host-mcp-20261027-20261027081850 → 0.221.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-native.eject.json +16 -1
- package/dist/app/agentkit/react/components.d.ts +5 -3
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +103 -42
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +93 -65
- package/dist/app/agentkit/react/voice-transcripts.d.ts +22 -1
- package/dist/app/agentkit/react/voice-transcripts.d.ts.map +1 -1
- package/dist/app/agentkit/react/voice-transcripts.js +209 -8
- package/dist/app/agentkit/react/voice-transcripts.js.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +55 -40
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts +1 -0
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.js +18 -6
- package/dist/app/chat/agentkit-chat/guided-questions.js.map +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/index.js +1 -1
- package/dist/app/chat/agentkit-chat/index.js.map +1 -1
- package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
- package/dist/app/chat/chat/run-recovery.js +5 -1
- package/dist/app/chat/chat/run-recovery.js.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.js +2 -0
- package/dist/app/chat/composer/runtime-adapters.js.map +1 -1
- package/dist/app/chat/reply-connection-suggestion.d.ts +29 -0
- package/dist/app/chat/reply-connection-suggestion.d.ts.map +1 -0
- package/dist/app/chat/reply-connection-suggestion.js +42 -0
- package/dist/app/chat/reply-connection-suggestion.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +7 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +7 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +7 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +7 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +7 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +7 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +7 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +7 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +7 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +7 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +7 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +7 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/mcp-approval/McpApprovalReview.d.ts +25 -0
- package/dist/app/mcp-approval/McpApprovalReview.d.ts.map +1 -0
- package/dist/app/mcp-approval/McpApprovalReview.js +342 -0
- package/dist/app/mcp-approval/McpApprovalReview.js.map +1 -0
- package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
- package/dist/app/onboarding/FirstRunOnboarding.js +299 -71
- package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
- package/dist/app/onboarding/GettingStartedCard.js +1 -1
- package/dist/app/onboarding/GettingStartedCard.js.map +1 -1
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts +2 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts.map +1 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js +8 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js.map +1 -0
- package/dist/app/providers/AppProviders.d.ts.map +1 -1
- package/dist/app/providers/AppProviders.js +14 -1
- package/dist/app/providers/AppProviders.js.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.d.ts +2 -4
- package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.js +59 -36
- package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.d.ts.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.js +14 -1
- package/dist/app/settings/account/PreferencesSettings.js.map +1 -1
- package/dist/app/settings/account/ThemeFamilySettings.js +2 -2
- package/dist/app/settings/account/ThemeFamilySettings.js.map +1 -1
- package/dist/app/settings/account/search-entries.d.ts +1 -0
- package/dist/app/settings/account/search-entries.d.ts.map +1 -1
- package/dist/app/settings/account/search-entries.js +5 -0
- package/dist/app/settings/account/search-entries.js.map +1 -1
- package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
- package/dist/app/settings/shell/SettingsShell.js +26 -2
- package/dist/app/settings/shell/SettingsShell.js.map +1 -1
- package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
- package/dist/app/shared/AppShellSkeleton.js +4 -10
- package/dist/app/shared/AppShellSkeleton.js.map +1 -1
- package/dist/app/shared/EnvironmentBadge.d.ts +2 -1
- package/dist/app/shared/EnvironmentBadge.d.ts.map +1 -1
- package/dist/app/shared/EnvironmentBadge.js +2 -3
- package/dist/app/shared/EnvironmentBadge.js.map +1 -1
- package/dist/app/shared/is-builder-io-employee.d.ts +8 -0
- package/dist/app/shared/is-builder-io-employee.d.ts.map +1 -0
- package/dist/app/shared/is-builder-io-employee.js +10 -0
- package/dist/app/shared/is-builder-io-employee.js.map +1 -0
- package/dist/app/styles/agent-conversation.css +30 -17
- package/dist/app/styles/theme-families.css +22 -5
- package/dist/app/styles/tokens/agent-kit.css +3 -3
- package/dist/composer/PromptComposer.d.ts +15 -3
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +74 -16
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.d.ts +7 -0
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +9 -2
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +7 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +12 -4
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/VoiceButton.d.ts +2 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -1
- package/dist/composer/VoiceButton.js +5 -4
- package/dist/composer/VoiceButton.js.map +1 -1
- package/dist/composer/index.d.ts +2 -2
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js +1 -1
- package/dist/composer/index.js.map +1 -1
- package/dist/composer/prompt-attachments.d.ts +20 -0
- package/dist/composer/prompt-attachments.d.ts.map +1 -1
- package/dist/composer/prompt-attachments.js +188 -11
- package/dist/composer/prompt-attachments.js.map +1 -1
- package/dist/composer/realtime-voice-transcript.d.ts +27 -0
- package/dist/composer/realtime-voice-transcript.d.ts.map +1 -1
- package/dist/composer/realtime-voice-transcript.js +34 -1
- package/dist/composer/realtime-voice-transcript.js.map +1 -1
- package/dist/composer/realtime-voice-turn-metrics.d.ts +43 -0
- package/dist/composer/realtime-voice-turn-metrics.d.ts.map +1 -0
- package/dist/composer/realtime-voice-turn-metrics.js +132 -0
- package/dist/composer/realtime-voice-turn-metrics.js.map +1 -0
- package/dist/composer/runtime-adapters.d.ts +4 -1
- package/dist/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/composer/runtime-adapters.js +2 -1
- package/dist/composer/runtime-adapters.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts +61 -9
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +536 -99
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/composer/voice-chat-icon.d.ts +7 -0
- package/dist/composer/voice-chat-icon.d.ts.map +1 -0
- package/dist/composer/voice-chat-icon.js +14 -0
- package/dist/composer/voice-chat-icon.js.map +1 -0
- package/dist/editor/DragHandle.d.ts +12 -1
- package/dist/editor/DragHandle.d.ts.map +1 -1
- package/dist/editor/DragHandle.js +165 -41
- package/dist/editor/DragHandle.js.map +1 -1
- package/dist/editor/RegistryBlockNode.d.ts +20 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/editor/RegistryBlockNode.js +58 -13
- package/dist/editor/RegistryBlockNode.js.map +1 -1
- package/dist/editor/index.d.ts +2 -2
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/dist/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/ui/dropdown-menu.js +5 -1
- package/dist/ui/dropdown-menu.js.map +1 -1
- package/package.json +14 -4
- package/src/app/agentkit/react/components.regenerate-readiness.spec.tsx +3 -1
- package/src/app/agentkit/react/components.spec.tsx +24 -4
- package/src/app/agentkit/react/components.tsx +174 -44
- package/src/app/agentkit/react/composer-context.spec.tsx +3 -0
- package/src/app/agentkit/react/disclosures.spec.tsx +124 -0
- package/src/app/agentkit/react/lifecycle.spec.tsx +8 -2
- package/src/app/agentkit/react/message-actions.spec.tsx +161 -5
- package/src/app/agentkit/react/saved-reply-reload.spec.tsx +206 -0
- package/src/app/agentkit/react/styles.css +93 -65
- package/src/app/agentkit/react/voice-transcripts.spec.tsx +239 -4
- package/src/app/agentkit/react/voice-transcripts.ts +245 -8
- package/src/app/chat/AgentKitAssistantChat.spec.tsx +59 -11
- package/src/app/chat/AgentKitAssistantChat.tsx +74 -62
- package/src/app/chat/agentkit-chat/guided-answers-context.spec.ts +9 -0
- package/src/app/chat/agentkit-chat/guided-questions.tsx +17 -7
- package/src/app/chat/agentkit-chat/index.ts +1 -0
- package/src/app/chat/chat/run-recovery.spec.tsx +45 -0
- package/src/app/chat/chat/run-recovery.tsx +6 -1
- package/src/app/chat/composer/runtime-adapters.tsx +2 -0
- package/src/app/chat/reply-connection-suggestion.spec.ts +121 -0
- package/src/app/chat/reply-connection-suggestion.ts +76 -0
- package/src/app/i18n/catalogs/ar-SA.ts +10 -0
- package/src/app/i18n/catalogs/de-DE.ts +10 -0
- package/src/app/i18n/catalogs/en-US.ts +10 -0
- package/src/app/i18n/catalogs/es-ES.ts +10 -0
- package/src/app/i18n/catalogs/fr-FR.ts +10 -0
- package/src/app/i18n/catalogs/hi-IN.ts +10 -0
- package/src/app/i18n/catalogs/ja-JP.ts +10 -0
- package/src/app/i18n/catalogs/ko-KR.ts +10 -0
- package/src/app/i18n/catalogs/pt-BR.ts +10 -0
- package/src/app/i18n/catalogs/zh-CN.ts +10 -0
- package/src/app/i18n/catalogs/zh-TW.ts +10 -0
- package/src/app/mcp-approval/McpApprovalReview.spec.tsx +687 -0
- package/src/app/mcp-approval/McpApprovalReview.tsx +913 -0
- package/src/app/onboarding/FirstRunOnboarding.spec.tsx +736 -26
- package/src/app/onboarding/FirstRunOnboarding.tsx +473 -145
- package/src/app/onboarding/GettingStartedCard.tsx +1 -1
- package/src/app/onboarding/first-run-builder-credits-flag.spec.ts +13 -0
- package/src/app/onboarding/first-run-builder-credits-flag.ts +9 -0
- package/src/app/providers/AppProviders.spec.tsx +109 -0
- package/src/app/providers/AppProviders.tsx +53 -1
- package/src/app/resources/McpConnectionSuggestion.spec.ts +77 -12
- package/src/app/resources/McpConnectionSuggestion.tsx +69 -51
- package/src/app/settings/VoiceChatSettings.spec.tsx +6 -1
- package/src/app/settings/account/PreferencesSettings.spec.tsx +73 -0
- package/src/app/settings/account/PreferencesSettings.tsx +24 -7
- package/src/app/settings/account/ThemeFamilySettings.tsx +2 -2
- package/src/app/settings/account/search-entries.spec.ts +14 -0
- package/src/app/settings/account/search-entries.ts +9 -0
- package/src/app/settings/shell/SettingsShell.tsx +29 -2
- package/src/app/shared/AppShellSkeleton.spec.tsx +2 -2
- package/src/app/shared/AppShellSkeleton.tsx +3 -16
- package/src/app/shared/EnvironmentBadge.tsx +3 -4
- package/src/app/shared/is-builder-io-employee.ts +17 -0
- package/src/app/styles/agent-conversation.css +30 -17
- package/src/app/styles/theme-families.css +22 -5
- package/src/app/styles/theme-families.spec.ts +73 -0
- package/src/app/styles/tokens/agent-kit.css +3 -3
- package/src/composer/AgentSuggestionBar.overflow.spec.tsx +1 -1
- package/src/composer/AttachmentChip.spec.tsx +87 -0
- package/src/composer/PromptComposer.context.spec.tsx +8 -4
- package/src/composer/PromptComposer.setup-card.spec.tsx +170 -0
- package/src/composer/PromptComposer.tsx +125 -14
- package/src/composer/RealtimeVoiceMode.spec.tsx +48 -0
- package/src/composer/RealtimeVoiceMode.tsx +45 -2
- package/src/composer/TiptapComposer.tsx +25 -0
- package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +4 -1
- package/src/composer/VoiceButton.tsx +8 -3
- package/src/composer/index.ts +4 -0
- package/src/composer/prompt-attachments.spec.ts +160 -8
- package/src/composer/prompt-attachments.ts +221 -12
- package/src/composer/realtime-voice-transcript.spec.ts +36 -0
- package/src/composer/realtime-voice-transcript.ts +56 -1
- package/src/composer/realtime-voice-turn-metrics.spec.ts +175 -0
- package/src/composer/realtime-voice-turn-metrics.ts +192 -0
- package/src/composer/runtime-adapters.tsx +5 -1
- package/src/composer/useRealtimeVoiceMode.session.spec.tsx +487 -2
- package/src/composer/useRealtimeVoiceMode.spec.ts +267 -65
- package/src/composer/useRealtimeVoiceMode.tsx +716 -139
- package/src/composer/voice-chat-icon.tsx +19 -0
- package/src/editor/DragHandle.spec.ts +217 -0
- package/src/editor/DragHandle.ts +214 -55
- package/src/editor/RegistryBlockNode.spec.ts +172 -2
- package/src/editor/RegistryBlockNode.tsx +91 -13
- package/src/editor/index.ts +4 -0
- package/src/styles.css +2 -0
- package/src/ui/dropdown-menu.tsx +4 -0
|
@@ -99,4 +99,40 @@ describe("realtime voice transcript registry", () => {
|
|
|
99
99
|
visible = true;
|
|
100
100
|
expect(registry.activeThreadId()).toBe("thread-1");
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it("queues a reply's work until its thread's sink takes it, finished over running", () => {
|
|
104
|
+
const registry = createRealtimeVoiceTranscriptRegistry();
|
|
105
|
+
const running = {
|
|
106
|
+
id: "call-1",
|
|
107
|
+
threadId: "thread-1",
|
|
108
|
+
calls: [
|
|
109
|
+
{
|
|
110
|
+
id: "call-1",
|
|
111
|
+
name: "web-search",
|
|
112
|
+
status: "running" as const,
|
|
113
|
+
startedAt: "2026-07-10T20:00:00.000Z",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
registry.publishWork(running);
|
|
118
|
+
registry.publishWork({
|
|
119
|
+
...running,
|
|
120
|
+
completedAt: "2026-07-10T20:00:02.000Z",
|
|
121
|
+
});
|
|
122
|
+
registry.publishWork(running);
|
|
123
|
+
expect(registry.pendingCount()).toBe(1);
|
|
124
|
+
|
|
125
|
+
const appendWork = vi.fn(() => true);
|
|
126
|
+
registry.register({
|
|
127
|
+
threadId: "thread-1",
|
|
128
|
+
active: true,
|
|
129
|
+
append: vi.fn(() => true),
|
|
130
|
+
appendWork,
|
|
131
|
+
});
|
|
132
|
+
expect(appendWork).toHaveBeenCalledOnce();
|
|
133
|
+
expect(appendWork).toHaveBeenCalledWith(
|
|
134
|
+
expect.objectContaining({ completedAt: "2026-07-10T20:00:02.000Z" }),
|
|
135
|
+
);
|
|
136
|
+
expect(registry.pendingCount()).toBe(0);
|
|
137
|
+
});
|
|
102
138
|
});
|
|
@@ -10,17 +10,50 @@ export interface RealtimeVoiceTranscriptMessage {
|
|
|
10
10
|
status?: "streaming" | "complete";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** One tool call in a voice reply's work. */
|
|
14
|
+
/** A tool name as a short label, such as "Edit design" for `edit-design`. */
|
|
15
|
+
export function realtimeVoiceToolLabel(name: string): string {
|
|
16
|
+
const label = name.replace(/[_-]+/g, " ").trim();
|
|
17
|
+
return label ? label.charAt(0).toUpperCase() + label.slice(1) : name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RealtimeVoiceWorkCall {
|
|
21
|
+
/** The provider's call id. */
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
input?: Record<string, unknown>;
|
|
25
|
+
status: "running" | "completed" | "failed" | "cancelled";
|
|
26
|
+
output?: string;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
completedAt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The tool calls one voice reply made, so the chat can show them as one work
|
|
33
|
+
* item. Each update is the whole reply so far under the same id.
|
|
34
|
+
*/
|
|
35
|
+
export interface RealtimeVoiceWorkUpdate {
|
|
36
|
+
id: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
calls: RealtimeVoiceWorkCall[];
|
|
39
|
+
/** Set once the reply has finished and every call in it has ended. */
|
|
40
|
+
completedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
export interface RealtimeVoiceTranscriptSink {
|
|
14
44
|
threadId: string;
|
|
15
45
|
/** Read when a voice session starts, so a sink can report whether it is on screen. */
|
|
16
46
|
active: boolean | (() => boolean);
|
|
17
47
|
/** False keeps the message queued, newest text per id, until the sink re-registers. */
|
|
18
48
|
append: (message: RealtimeVoiceTranscriptMessage) => boolean;
|
|
49
|
+
/** False keeps the update queued, newest per reply, until the sink re-registers. */
|
|
50
|
+
appendWork?: (update: RealtimeVoiceWorkUpdate) => boolean;
|
|
19
51
|
}
|
|
20
52
|
|
|
21
53
|
export interface RealtimeVoiceTranscriptRegistry {
|
|
22
54
|
activeThreadId: () => string | undefined;
|
|
23
55
|
publish: (message: RealtimeVoiceTranscriptMessage) => void;
|
|
56
|
+
publishWork: (update: RealtimeVoiceWorkUpdate) => void;
|
|
24
57
|
register: (sink: RealtimeVoiceTranscriptSink) => () => void;
|
|
25
58
|
pendingCount: () => number;
|
|
26
59
|
}
|
|
@@ -31,10 +64,23 @@ export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscript
|
|
|
31
64
|
RealtimeVoiceTranscriptSink & { order: number }
|
|
32
65
|
>();
|
|
33
66
|
const pending = new Map<string, RealtimeVoiceTranscriptMessage>();
|
|
67
|
+
const pendingWork = new Map<string, RealtimeVoiceWorkUpdate>();
|
|
34
68
|
const delivered = new Set<string>();
|
|
35
69
|
let order = 0;
|
|
36
70
|
|
|
37
71
|
const flush = () => {
|
|
72
|
+
for (const [id, update] of pendingWork) {
|
|
73
|
+
let consumed = false;
|
|
74
|
+
for (const sink of sinks.values()) {
|
|
75
|
+
if (sink.threadId !== update.threadId || !sink.appendWork) continue;
|
|
76
|
+
try {
|
|
77
|
+
consumed = sink.appendWork(update) || consumed;
|
|
78
|
+
} catch {
|
|
79
|
+
// coercion-ok: a throwing sink leaves the update queued for the next flush.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (consumed) pendingWork.delete(id);
|
|
83
|
+
}
|
|
38
84
|
for (const [id, message] of pending) {
|
|
39
85
|
let consumed = false;
|
|
40
86
|
for (const sink of sinks.values()) {
|
|
@@ -86,13 +132,22 @@ export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscript
|
|
|
86
132
|
});
|
|
87
133
|
flush();
|
|
88
134
|
},
|
|
135
|
+
publishWork(update) {
|
|
136
|
+
const id = update.id.trim();
|
|
137
|
+
const threadId = update.threadId.trim();
|
|
138
|
+
if (!id || !threadId) return;
|
|
139
|
+
// A finished reply outranks a late update from while it ran.
|
|
140
|
+
if (pendingWork.get(id)?.completedAt && !update.completedAt) return;
|
|
141
|
+
pendingWork.set(id, { ...update, id, threadId });
|
|
142
|
+
flush();
|
|
143
|
+
},
|
|
89
144
|
register(sink) {
|
|
90
145
|
const token = Symbol("realtime-voice-transcript-sink");
|
|
91
146
|
sinks.set(token, { ...sink, order: ++order });
|
|
92
147
|
flush();
|
|
93
148
|
return () => sinks.delete(token);
|
|
94
149
|
},
|
|
95
|
-
pendingCount: () => pending.size,
|
|
150
|
+
pendingCount: () => pending.size + pendingWork.size,
|
|
96
151
|
};
|
|
97
152
|
}
|
|
98
153
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createRealtimeVoiceTurnMetrics,
|
|
5
|
+
REALTIME_VOICE_TURN_EVENT,
|
|
6
|
+
} from "./realtime-voice-turn-metrics.js";
|
|
7
|
+
|
|
8
|
+
function metrics() {
|
|
9
|
+
const emit = vi.fn();
|
|
10
|
+
const tracker = createRealtimeVoiceTurnMetrics(emit);
|
|
11
|
+
tracker.setContext({
|
|
12
|
+
protocol: "live",
|
|
13
|
+
intelligence: "balanced",
|
|
14
|
+
intelligenceSource: "default",
|
|
15
|
+
});
|
|
16
|
+
return { emit, tracker };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("realtime voice turn metrics", () => {
|
|
20
|
+
it("times speech end to first audio on the provider's session clock", () => {
|
|
21
|
+
const { emit, tracker } = metrics();
|
|
22
|
+
tracker.userTurnUpdated(1_000, "user-turn-1", 40_000);
|
|
23
|
+
tracker.userTurnUpdated(1_100, "user-turn-1", 40_200);
|
|
24
|
+
tracker.assistantAudioStarted(1_500, 41_100);
|
|
25
|
+
tracker.assistantAudioStarted(1_600, 41_400);
|
|
26
|
+
tracker.sessionEnded();
|
|
27
|
+
|
|
28
|
+
expect(emit).toHaveBeenCalledWith(REALTIME_VOICE_TURN_EVENT, {
|
|
29
|
+
protocol: "live",
|
|
30
|
+
intelligence: "balanced",
|
|
31
|
+
intelligence_source: "default",
|
|
32
|
+
turn_index: 0,
|
|
33
|
+
speech_to_audio_ms: 900,
|
|
34
|
+
latency_clock: "session",
|
|
35
|
+
tool_calls: 0,
|
|
36
|
+
write_tool_calls: 0,
|
|
37
|
+
failed_tool_calls: 0,
|
|
38
|
+
spoke_again_after_edit: null,
|
|
39
|
+
ms_after_edit: null,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("flags speech within the window after an edit as a likely correction", () => {
|
|
44
|
+
const { emit, tracker } = metrics();
|
|
45
|
+
tracker.userSpeechStarted(0);
|
|
46
|
+
tracker.userSpeechEnded(1_000);
|
|
47
|
+
tracker.assistantAudioStarted(1_400);
|
|
48
|
+
tracker.toolCallStarted()(2_000, { status: "completed", readOnly: true });
|
|
49
|
+
tracker.toolCallStarted()(3_000, { status: "completed", readOnly: false });
|
|
50
|
+
tracker.toolCallStarted()(3_500, { status: "completed" });
|
|
51
|
+
tracker.userSpeechStarted(7_000);
|
|
52
|
+
|
|
53
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({
|
|
54
|
+
speech_to_audio_ms: 400,
|
|
55
|
+
latency_clock: "browser",
|
|
56
|
+
tool_calls: 3,
|
|
57
|
+
write_tool_calls: 1,
|
|
58
|
+
spoke_again_after_edit: true,
|
|
59
|
+
ms_after_edit: 4_000,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not count a reply long after the edit, or a failed edit", () => {
|
|
64
|
+
const { emit, tracker } = metrics();
|
|
65
|
+
tracker.userSpeechEnded(0);
|
|
66
|
+
tracker.toolCallStarted()(500, { status: "completed", readOnly: false });
|
|
67
|
+
tracker.userSpeechStarted(20_000);
|
|
68
|
+
tracker.userSpeechEnded(21_000);
|
|
69
|
+
tracker.toolCallStarted()(21_500, { status: "failed" });
|
|
70
|
+
tracker.userSpeechStarted(22_000);
|
|
71
|
+
|
|
72
|
+
expect(emit.mock.calls.map(([, properties]) => properties)).toMatchObject([
|
|
73
|
+
{ turn_index: 0, spoke_again_after_edit: false, ms_after_edit: 19_500 },
|
|
74
|
+
{
|
|
75
|
+
turn_index: 1,
|
|
76
|
+
failed_tool_calls: 1,
|
|
77
|
+
write_tool_calls: 0,
|
|
78
|
+
spoke_again_after_edit: null,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("counts neither an unclassified nor a cancelled call as an edit or a failure", () => {
|
|
84
|
+
const { emit, tracker } = metrics();
|
|
85
|
+
tracker.userSpeechEnded(0);
|
|
86
|
+
tracker.toolCallStarted()(500, { status: "completed" });
|
|
87
|
+
tracker.toolCallStarted()(600, { status: "cancelled" });
|
|
88
|
+
tracker.userSpeechStarted(1_000);
|
|
89
|
+
|
|
90
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({
|
|
91
|
+
tool_calls: 2,
|
|
92
|
+
write_tool_calls: 0,
|
|
93
|
+
failed_tool_calls: 0,
|
|
94
|
+
spoke_again_after_edit: null,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps one turn while the user pauses with no reply yet", () => {
|
|
99
|
+
const { emit, tracker } = metrics();
|
|
100
|
+
tracker.userSpeechStarted(0);
|
|
101
|
+
tracker.userSpeechEnded(1_000);
|
|
102
|
+
tracker.userSpeechStarted(1_200);
|
|
103
|
+
tracker.userSpeechEnded(2_000);
|
|
104
|
+
tracker.assistantAudioStarted(2_300);
|
|
105
|
+
tracker.sessionEnded();
|
|
106
|
+
|
|
107
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({ speech_to_audio_ms: 300 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("ignores late text from a GPT-Live turn the assistant is already answering", () => {
|
|
112
|
+
const { emit, tracker } = metrics();
|
|
113
|
+
tracker.userTurnUpdated(1_000, "user-turn-1", 40_000);
|
|
114
|
+
tracker.assistantAudioStarted(1_400, 40_600);
|
|
115
|
+
tracker.userTurnUpdated(1_500, "user-turn-1", 40_100);
|
|
116
|
+
tracker.userTurnUpdated(9_000, "user-turn-2", 48_000);
|
|
117
|
+
|
|
118
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({
|
|
120
|
+
turn_index: 0,
|
|
121
|
+
speech_to_audio_ms: 600,
|
|
122
|
+
latency_clock: "session",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("counts a tool result in the turn it started in, after an interruption", () => {
|
|
127
|
+
const { emit, tracker } = metrics();
|
|
128
|
+
tracker.userSpeechEnded(0);
|
|
129
|
+
const finishEdit = tracker.toolCallStarted();
|
|
130
|
+
// The user speaks again while the edit is still running.
|
|
131
|
+
tracker.userSpeechStarted(1_000);
|
|
132
|
+
tracker.userSpeechEnded(1_500);
|
|
133
|
+
expect(emit).not.toHaveBeenCalled();
|
|
134
|
+
|
|
135
|
+
finishEdit(2_000, { status: "completed", readOnly: false });
|
|
136
|
+
expect(emit).toHaveBeenCalledTimes(1);
|
|
137
|
+
// Speaking before the edit finished is not a reaction to it.
|
|
138
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({
|
|
139
|
+
turn_index: 0,
|
|
140
|
+
tool_calls: 1,
|
|
141
|
+
write_tool_calls: 1,
|
|
142
|
+
spoke_again_after_edit: false,
|
|
143
|
+
ms_after_edit: null,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
tracker.assistantResponded();
|
|
147
|
+
tracker.sessionEnded();
|
|
148
|
+
expect(emit.mock.calls[1]![1]).toMatchObject({
|
|
149
|
+
turn_index: 1,
|
|
150
|
+
tool_calls: 0,
|
|
151
|
+
write_tool_calls: 0,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("measures a text-only reply without a first-audio time", () => {
|
|
156
|
+
const { emit, tracker } = metrics();
|
|
157
|
+
tracker.userSpeechEnded(0);
|
|
158
|
+
tracker.assistantResponded();
|
|
159
|
+
tracker.userSpeechStarted(5_000);
|
|
160
|
+
|
|
161
|
+
expect(emit.mock.calls[0]![1]).toMatchObject({
|
|
162
|
+
speech_to_audio_ms: null,
|
|
163
|
+
latency_clock: null,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("skips a greeting nobody asked for and an empty session", () => {
|
|
168
|
+
const { emit, tracker } = metrics();
|
|
169
|
+
tracker.assistantAudioStarted(100);
|
|
170
|
+
tracker.sessionEnded();
|
|
171
|
+
tracker.sessionEnded();
|
|
172
|
+
|
|
173
|
+
expect(emit).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
export const REALTIME_VOICE_TURN_EVENT = "realtime_voice_turn";
|
|
2
|
+
export const REALTIME_VOICE_CORRECTION_WINDOW_MS = 10_000;
|
|
3
|
+
|
|
4
|
+
export interface RealtimeVoiceTurnMetricsContext {
|
|
5
|
+
protocol: "live" | "realtime";
|
|
6
|
+
intelligence: string;
|
|
7
|
+
intelligenceSource: "user" | "default";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Records how a tool call that started in a turn ended. Only a call classified
|
|
12
|
+
* `readOnly: false` counts as an edit; an unclassified call is neither.
|
|
13
|
+
*/
|
|
14
|
+
export type RealtimeVoiceTurnToolCallFinish = (
|
|
15
|
+
at: number,
|
|
16
|
+
result: { status: string; readOnly?: boolean | undefined },
|
|
17
|
+
) => void;
|
|
18
|
+
|
|
19
|
+
export interface RealtimeVoiceTurnMetrics {
|
|
20
|
+
setContext: (context: RealtimeVoiceTurnMetricsContext) => void;
|
|
21
|
+
/** Realtime: the provider heard the user start speaking. */
|
|
22
|
+
userSpeechStarted: (at: number) => void;
|
|
23
|
+
/** Realtime: the provider heard the user stop speaking. */
|
|
24
|
+
userSpeechEnded: (at: number) => void;
|
|
25
|
+
/**
|
|
26
|
+
* GPT-Live: a user turn's transcript grew. `turnId` names the transcript
|
|
27
|
+
* turn and `endMs` is its end on the session clock, so late text from a turn
|
|
28
|
+
* already answered never starts another.
|
|
29
|
+
*/
|
|
30
|
+
userTurnUpdated: (at: number, turnId: string, endMs: number) => void;
|
|
31
|
+
/** The assistant replied in text, which carries no audio timing. */
|
|
32
|
+
assistantResponded: () => void;
|
|
33
|
+
/** `timelineMs` is the provider's session clock, when the event has one. */
|
|
34
|
+
assistantAudioStarted: (at: number, timelineMs?: number) => void;
|
|
35
|
+
/** The returned function records the call's outcome against this turn. */
|
|
36
|
+
toolCallStarted: () => RealtimeVoiceTurnToolCallFinish;
|
|
37
|
+
/** Reports the open turn; nobody can speak again after the session ends. */
|
|
38
|
+
sessionEnded: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Turn {
|
|
42
|
+
userTurnId?: string | undefined;
|
|
43
|
+
speechEndedAt?: number | undefined;
|
|
44
|
+
speechEndedTimelineMs?: number | undefined;
|
|
45
|
+
firstAudioAt?: number | undefined;
|
|
46
|
+
firstAudioTimelineMs?: number | undefined;
|
|
47
|
+
responding: boolean;
|
|
48
|
+
toolCalls: number;
|
|
49
|
+
pendingToolCalls: number;
|
|
50
|
+
writeToolCalls: number;
|
|
51
|
+
failedToolCalls: number;
|
|
52
|
+
lastWriteCompletedAt?: number | undefined;
|
|
53
|
+
/** Reported while a call still ran; it is sent once the last call ends. */
|
|
54
|
+
heldReport?: { index: number; nextSpeechAt: number | undefined } | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emptyTurn(): Turn {
|
|
58
|
+
return {
|
|
59
|
+
responding: false,
|
|
60
|
+
toolCalls: 0,
|
|
61
|
+
pendingToolCalls: 0,
|
|
62
|
+
writeToolCalls: 0,
|
|
63
|
+
failedToolCalls: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One event per voice turn: from the end of the user's speech to the next time
|
|
69
|
+
* they speak. It carries timings and counts only, never transcript text or
|
|
70
|
+
* tool arguments.
|
|
71
|
+
*/
|
|
72
|
+
export function createRealtimeVoiceTurnMetrics(
|
|
73
|
+
emit: (name: string, properties: Record<string, unknown>) => void,
|
|
74
|
+
correctionWindowMs = REALTIME_VOICE_CORRECTION_WINDOW_MS,
|
|
75
|
+
): RealtimeVoiceTurnMetrics {
|
|
76
|
+
let context: RealtimeVoiceTurnMetricsContext | undefined;
|
|
77
|
+
let turn = emptyTurn();
|
|
78
|
+
let turnIndex = 0;
|
|
79
|
+
|
|
80
|
+
const send = (
|
|
81
|
+
current: Turn,
|
|
82
|
+
index: number,
|
|
83
|
+
nextSpeechAt: number | undefined,
|
|
84
|
+
) => {
|
|
85
|
+
const { speechEndedTimelineMs, firstAudioTimelineMs } = current;
|
|
86
|
+
const sessionClock =
|
|
87
|
+
speechEndedTimelineMs !== undefined && firstAudioTimelineMs !== undefined;
|
|
88
|
+
const speechToAudioMs = sessionClock
|
|
89
|
+
? firstAudioTimelineMs - speechEndedTimelineMs
|
|
90
|
+
: current.speechEndedAt !== undefined &&
|
|
91
|
+
current.firstAudioAt !== undefined
|
|
92
|
+
? current.firstAudioAt - current.speechEndedAt
|
|
93
|
+
: null;
|
|
94
|
+
// Speech that began while the edit still ran did not answer the edit.
|
|
95
|
+
const msAfterEdit =
|
|
96
|
+
current.lastWriteCompletedAt !== undefined &&
|
|
97
|
+
nextSpeechAt !== undefined &&
|
|
98
|
+
nextSpeechAt >= current.lastWriteCompletedAt
|
|
99
|
+
? Math.round(nextSpeechAt - current.lastWriteCompletedAt)
|
|
100
|
+
: null;
|
|
101
|
+
emit(REALTIME_VOICE_TURN_EVENT, {
|
|
102
|
+
protocol: context?.protocol ?? null,
|
|
103
|
+
intelligence: context?.intelligence ?? null,
|
|
104
|
+
intelligence_source: context?.intelligenceSource ?? null,
|
|
105
|
+
turn_index: index,
|
|
106
|
+
speech_to_audio_ms:
|
|
107
|
+
speechToAudioMs === null
|
|
108
|
+
? null
|
|
109
|
+
: Math.max(0, Math.round(speechToAudioMs)),
|
|
110
|
+
latency_clock:
|
|
111
|
+
speechToAudioMs === null ? null : sessionClock ? "session" : "browser",
|
|
112
|
+
tool_calls: current.toolCalls,
|
|
113
|
+
write_tool_calls: current.writeToolCalls,
|
|
114
|
+
failed_tool_calls: current.failedToolCalls,
|
|
115
|
+
spoke_again_after_edit:
|
|
116
|
+
current.lastWriteCompletedAt === undefined
|
|
117
|
+
? null
|
|
118
|
+
: msAfterEdit !== null && msAfterEdit <= correctionWindowMs,
|
|
119
|
+
ms_after_edit: msAfterEdit,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const report = (nextSpeechAt?: number) => {
|
|
124
|
+
const current = turn;
|
|
125
|
+
turn = emptyTurn();
|
|
126
|
+
if (current.speechEndedAt === undefined && current.toolCalls === 0) return;
|
|
127
|
+
const index = turnIndex++;
|
|
128
|
+
if (current.pendingToolCalls > 0) {
|
|
129
|
+
current.heldReport = { index, nextSpeechAt };
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
send(current, index, nextSpeechAt);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
setContext(next) {
|
|
137
|
+
context = next;
|
|
138
|
+
},
|
|
139
|
+
userSpeechStarted(at) {
|
|
140
|
+
if (turn.responding) report(at);
|
|
141
|
+
},
|
|
142
|
+
userSpeechEnded(at) {
|
|
143
|
+
if (turn.responding) report(at);
|
|
144
|
+
turn.speechEndedAt = at;
|
|
145
|
+
},
|
|
146
|
+
userTurnUpdated(at, turnId, endMs) {
|
|
147
|
+
if (turn.userTurnId === turnId) {
|
|
148
|
+
if (turn.responding) return;
|
|
149
|
+
} else if (turn.responding) {
|
|
150
|
+
report(at);
|
|
151
|
+
}
|
|
152
|
+
turn.userTurnId = turnId;
|
|
153
|
+
turn.speechEndedAt = at;
|
|
154
|
+
turn.speechEndedTimelineMs = endMs;
|
|
155
|
+
},
|
|
156
|
+
assistantResponded() {
|
|
157
|
+
turn.responding = true;
|
|
158
|
+
},
|
|
159
|
+
assistantAudioStarted(at, timelineMs) {
|
|
160
|
+
turn.responding = true;
|
|
161
|
+
if (turn.firstAudioAt !== undefined) return;
|
|
162
|
+
turn.firstAudioAt = at;
|
|
163
|
+
turn.firstAudioTimelineMs = timelineMs;
|
|
164
|
+
},
|
|
165
|
+
toolCallStarted() {
|
|
166
|
+
const owner = turn;
|
|
167
|
+
owner.responding = true;
|
|
168
|
+
owner.toolCalls += 1;
|
|
169
|
+
owner.pendingToolCalls += 1;
|
|
170
|
+
let finished = false;
|
|
171
|
+
return (at, result) => {
|
|
172
|
+
if (finished) return;
|
|
173
|
+
finished = true;
|
|
174
|
+
owner.pendingToolCalls -= 1;
|
|
175
|
+
if (result.status === "failed") {
|
|
176
|
+
owner.failedToolCalls += 1;
|
|
177
|
+
} else if (result.status === "completed" && result.readOnly === false) {
|
|
178
|
+
owner.writeToolCalls += 1;
|
|
179
|
+
owner.lastWriteCompletedAt = at;
|
|
180
|
+
}
|
|
181
|
+
const held = owner.heldReport;
|
|
182
|
+
if (owner.pendingToolCalls > 0 || !held) return;
|
|
183
|
+
owner.heldReport = undefined;
|
|
184
|
+
send(owner, held.index, held.nextSpeechAt);
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
sessionEnded() {
|
|
188
|
+
report();
|
|
189
|
+
turnIndex = 0;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -267,11 +267,13 @@ export interface ComposerRuntimeAdapters {
|
|
|
267
267
|
text: string,
|
|
268
268
|
context: VoiceContextPack | undefined,
|
|
269
269
|
) => string;
|
|
270
|
+
/** Sends a voice analytics event; properties never carry transcript text. */
|
|
271
|
+
trackEvent?: (name: string, properties: Record<string, unknown>) => void;
|
|
270
272
|
};
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
const identityPath = (path: string) => path;
|
|
274
|
-
const fallbackTranslate: ComposerTranslate = (key, options) => {
|
|
276
|
+
export const fallbackTranslate: ComposerTranslate = (key, options) => {
|
|
275
277
|
const template =
|
|
276
278
|
typeof options?.defaultValue === "string" ? options.defaultValue : key;
|
|
277
279
|
|
|
@@ -352,6 +354,7 @@ type ResolvedComposerRuntimeAdapters = ComposerRuntimeAdapters &
|
|
|
352
354
|
| "setAppState"
|
|
353
355
|
| "subscribeSidebarState"
|
|
354
356
|
| "applyContextReplacements"
|
|
357
|
+
| "trackEvent"
|
|
355
358
|
>
|
|
356
359
|
>;
|
|
357
360
|
};
|
|
@@ -394,6 +397,7 @@ const fallbackAdapters: ResolvedComposerRuntimeAdapters = {
|
|
|
394
397
|
setAppState: () => {},
|
|
395
398
|
subscribeSidebarState: () => () => {},
|
|
396
399
|
applyContextReplacements: applyVoiceContextReplacements,
|
|
400
|
+
trackEvent: () => {},
|
|
397
401
|
},
|
|
398
402
|
};
|
|
399
403
|
|