@frockbot/plugin-voice 0.0.0 → 0.3.21
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/frockbot.json +25 -0
- package/package.json +45 -6
- package/src/ask.test.ts +129 -0
- package/src/ask.ts +191 -0
- package/src/backend.ts +63 -0
- package/src/bot.test.ts +78 -0
- package/src/bot.ts +158 -0
- package/src/client/VoiceSurface.vue +70 -0
- package/src/client/VoiceToggle.vue +32 -0
- package/src/client/index.test.ts +95 -0
- package/src/client/index.ts +330 -0
- package/src/client/pending-notifications.test.ts +63 -0
- package/src/client/pending-notifications.ts +80 -0
- package/src/client/playback.test.ts +13 -0
- package/src/client/playback.ts +79 -0
- package/src/client/state.ts +25 -0
- package/src/client/styles.css +137 -0
- package/src/index.ts +6 -0
- package/src/ledger.test.ts +246 -0
- package/src/ledger.ts +499 -0
- package/src/manifest.ts +3 -0
- package/src/prompt.ts +18 -0
- package/src/shared.ts +509 -0
- package/src/tools.test.ts +79 -0
- package/src/tools.ts +254 -0
- package/src/user.ts +145 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { deliverPendingVoiceNotificationsV1 } from "./pending-notifications.js";
|
|
3
|
+
|
|
4
|
+
function storage() {
|
|
5
|
+
const values = new Map<string, string>();
|
|
6
|
+
return {
|
|
7
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
8
|
+
setItem: (key: string, value: string) => void values.set(key, value),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const answer = {
|
|
13
|
+
schemaVersion: 1 as const,
|
|
14
|
+
answerId: "ask-1",
|
|
15
|
+
botId: "research",
|
|
16
|
+
botName: "Research",
|
|
17
|
+
question: "What changed?",
|
|
18
|
+
answer: "The release landed.",
|
|
19
|
+
answeredAt: "2026-09-04T01:02:03.000Z",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("pending Voice notifications", () => {
|
|
23
|
+
test("fires the client notification seam once per pending answer", async () => {
|
|
24
|
+
const shared = storage();
|
|
25
|
+
const notify = mock((_intent: { title: string; body: string }) =>
|
|
26
|
+
Promise.resolve("web" as const),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(
|
|
30
|
+
await deliverPendingVoiceNotificationsV1([answer], {
|
|
31
|
+
storage: shared,
|
|
32
|
+
notify,
|
|
33
|
+
}),
|
|
34
|
+
).toBe(1);
|
|
35
|
+
expect(
|
|
36
|
+
await deliverPendingVoiceNotificationsV1([answer], {
|
|
37
|
+
storage: shared,
|
|
38
|
+
notify,
|
|
39
|
+
}),
|
|
40
|
+
).toBe(0);
|
|
41
|
+
expect(notify).toHaveBeenCalledWith({
|
|
42
|
+
title: "Research answered",
|
|
43
|
+
body: "The release landed.",
|
|
44
|
+
});
|
|
45
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns an unavailable notification claim for a later retry", async () => {
|
|
49
|
+
const shared = storage();
|
|
50
|
+
const notify = mock((_intent: { title: string; body: string }) =>
|
|
51
|
+
Promise.resolve("unavailable" as const),
|
|
52
|
+
);
|
|
53
|
+
await deliverPendingVoiceNotificationsV1([answer], {
|
|
54
|
+
storage: shared,
|
|
55
|
+
notify,
|
|
56
|
+
});
|
|
57
|
+
await deliverPendingVoiceNotificationsV1([answer], {
|
|
58
|
+
storage: shared,
|
|
59
|
+
notify,
|
|
60
|
+
});
|
|
61
|
+
expect(notify).toHaveBeenCalledTimes(2);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { showClientNotificationV1 } from "@frockbot/plugin-shell/client/notify";
|
|
2
|
+
import type { VoicePendingAnswerV1 } from "../shared.js";
|
|
3
|
+
|
|
4
|
+
const DELIVERED_KEY_V1 = "frockbot.voice.delivered-answers.v1";
|
|
5
|
+
const DELIVERED_LIMIT_V1 = 200;
|
|
6
|
+
type WritableStorage = Pick<Storage, "getItem" | "setItem">;
|
|
7
|
+
|
|
8
|
+
function browserStorageV1(): WritableStorage | undefined {
|
|
9
|
+
try {
|
|
10
|
+
return typeof localStorage === "undefined" ? undefined : localStorage;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readIdsV1(storage: WritableStorage): string[] {
|
|
17
|
+
try {
|
|
18
|
+
const value: unknown = JSON.parse(
|
|
19
|
+
storage.getItem(DELIVERED_KEY_V1) ?? "[]",
|
|
20
|
+
);
|
|
21
|
+
return Array.isArray(value)
|
|
22
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
23
|
+
: [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function claimV1(answerId: string, storage: WritableStorage | undefined) {
|
|
30
|
+
if (!storage) return true;
|
|
31
|
+
try {
|
|
32
|
+
const ids = readIdsV1(storage);
|
|
33
|
+
if (ids.includes(answerId)) return false;
|
|
34
|
+
storage.setItem(
|
|
35
|
+
DELIVERED_KEY_V1,
|
|
36
|
+
JSON.stringify([...ids, answerId].slice(-DELIVERED_LIMIT_V1)),
|
|
37
|
+
);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function releaseV1(answerId: string, storage: WritableStorage | undefined) {
|
|
45
|
+
if (!storage) return;
|
|
46
|
+
try {
|
|
47
|
+
storage.setItem(
|
|
48
|
+
DELIVERED_KEY_V1,
|
|
49
|
+
JSON.stringify(readIdsV1(storage).filter((id) => id !== answerId)),
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
// A denied store cannot silence a later notification attempt.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shows each pending answer once per browser while durable Voice is off. */
|
|
57
|
+
export async function deliverPendingVoiceNotificationsV1(
|
|
58
|
+
answers: readonly VoicePendingAnswerV1[],
|
|
59
|
+
options: {
|
|
60
|
+
storage?: WritableStorage;
|
|
61
|
+
notify?: typeof showClientNotificationV1;
|
|
62
|
+
} = {},
|
|
63
|
+
): Promise<number> {
|
|
64
|
+
const storage = options.storage ?? browserStorageV1();
|
|
65
|
+
const notify = options.notify ?? showClientNotificationV1;
|
|
66
|
+
let delivered = 0;
|
|
67
|
+
for (const answer of answers) {
|
|
68
|
+
if (!claimV1(answer.answerId, storage)) continue;
|
|
69
|
+
const result = await notify({
|
|
70
|
+
title: `${answer.botName} answered`,
|
|
71
|
+
body: answer.answer,
|
|
72
|
+
});
|
|
73
|
+
if (result === "unavailable") {
|
|
74
|
+
releaseV1(answer.answerId, storage);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
delivered += 1;
|
|
78
|
+
}
|
|
79
|
+
return delivered;
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pcm16LeToFloat32V1 } from "./playback.js";
|
|
3
|
+
|
|
4
|
+
describe("Voice PCM playback", () => {
|
|
5
|
+
test("decodes little-endian signed PCM16 into browser samples", () => {
|
|
6
|
+
const pcm = new Uint8Array([0x00, 0x80, 0x00, 0x00, 0xff, 0x7f]);
|
|
7
|
+
expect([...pcm16LeToFloat32V1(pcm.buffer)]).toEqual([
|
|
8
|
+
-1,
|
|
9
|
+
0,
|
|
10
|
+
32_767 / 32_768,
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** PCM16LE playback for Gemini Live's 24 kHz output. */
|
|
2
|
+
export interface VoicePlaybackV1 {
|
|
3
|
+
play(pcm16: ArrayBuffer): void;
|
|
4
|
+
interrupt(): void;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface AudioContextLikeV1 {
|
|
9
|
+
currentTime: number;
|
|
10
|
+
destination: AudioDestinationNode;
|
|
11
|
+
state: AudioContextState;
|
|
12
|
+
createBuffer(
|
|
13
|
+
channels: number,
|
|
14
|
+
length: number,
|
|
15
|
+
sampleRate: number,
|
|
16
|
+
): AudioBuffer;
|
|
17
|
+
createBufferSource(): AudioBufferSourceNode;
|
|
18
|
+
resume(): Promise<void>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function pcm16LeToFloat32V1(input: ArrayBuffer): Float32Array {
|
|
23
|
+
const view = new DataView(input);
|
|
24
|
+
const result = new Float32Array(Math.floor(input.byteLength / 2));
|
|
25
|
+
for (let index = 0; index < result.length; index += 1) {
|
|
26
|
+
result[index] = view.getInt16(index * 2, true) / 32_768;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createVoicePlaybackV1(
|
|
32
|
+
context: AudioContextLikeV1 = new AudioContext(),
|
|
33
|
+
sampleRate = 24_000,
|
|
34
|
+
): VoicePlaybackV1 {
|
|
35
|
+
const playing = new Set<AudioBufferSourceNode>();
|
|
36
|
+
let nextAt = 0;
|
|
37
|
+
let closed = false;
|
|
38
|
+
if (context.state === "suspended") void context.resume();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
play(pcm16) {
|
|
42
|
+
if (closed || pcm16.byteLength < 2) return;
|
|
43
|
+
const samples = pcm16LeToFloat32V1(pcm16);
|
|
44
|
+
const buffer = context.createBuffer(1, samples.length, sampleRate);
|
|
45
|
+
const channel = buffer.getChannelData(0);
|
|
46
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
47
|
+
channel[index] = samples[index] ?? 0;
|
|
48
|
+
}
|
|
49
|
+
const source = context.createBufferSource();
|
|
50
|
+
source.buffer = buffer;
|
|
51
|
+
source.connect(context.destination);
|
|
52
|
+
const startAt = Math.max(context.currentTime, nextAt);
|
|
53
|
+
nextAt = startAt + buffer.duration;
|
|
54
|
+
playing.add(source);
|
|
55
|
+
source.onended = () => {
|
|
56
|
+
playing.delete(source);
|
|
57
|
+
source.disconnect();
|
|
58
|
+
};
|
|
59
|
+
source.start(startAt);
|
|
60
|
+
},
|
|
61
|
+
interrupt() {
|
|
62
|
+
nextAt = context.currentTime;
|
|
63
|
+
for (const source of playing) {
|
|
64
|
+
try {
|
|
65
|
+
source.stop();
|
|
66
|
+
} catch {
|
|
67
|
+
// It may have ended between enumeration and stop.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
playing.clear();
|
|
71
|
+
},
|
|
72
|
+
async close() {
|
|
73
|
+
if (closed) return;
|
|
74
|
+
closed = true;
|
|
75
|
+
this.interrupt();
|
|
76
|
+
await context.close().catch(() => undefined);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { VoiceAssistantLiveStateV1 } from "@frockbot/protocol";
|
|
2
|
+
import type {
|
|
3
|
+
VoiceSessionRecordV1,
|
|
4
|
+
VoiceToolCallEntryV1,
|
|
5
|
+
VoiceTranscriptEntryV1,
|
|
6
|
+
} from "../shared.js";
|
|
7
|
+
import type { InjectionKey, Ref } from "vue";
|
|
8
|
+
|
|
9
|
+
export interface VoiceClientStateV1 {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
status: VoiceAssistantLiveStateV1;
|
|
12
|
+
level: number;
|
|
13
|
+
quotaRemainingSeconds: number;
|
|
14
|
+
quotaLimitSeconds: number;
|
|
15
|
+
session?: Pick<VoiceSessionRecordV1, "sessionId" | "startedAt">;
|
|
16
|
+
transcript: VoiceTranscriptEntryV1[];
|
|
17
|
+
tools: VoiceToolCallEntryV1[];
|
|
18
|
+
message?: string;
|
|
19
|
+
refresh(): Promise<void>;
|
|
20
|
+
toggle(): Promise<void>;
|
|
21
|
+
open(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const voiceClientStateKey: InjectionKey<Ref<VoiceClientStateV1>> =
|
|
25
|
+
Symbol("frockbot.voice.client-state");
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
.voice-toggle {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: grid;
|
|
4
|
+
width: var(--frock-avatar-sm);
|
|
5
|
+
height: var(--frock-avatar-sm);
|
|
6
|
+
place-items: center;
|
|
7
|
+
border: 0;
|
|
8
|
+
border-radius: var(--frock-radius-control);
|
|
9
|
+
color: var(--frock-text-muted);
|
|
10
|
+
background: transparent;
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.voice-toggle:hover,
|
|
15
|
+
.voice-toggle--listening,
|
|
16
|
+
.voice-toggle--speaking {
|
|
17
|
+
color: var(--frock-text);
|
|
18
|
+
background: var(--frock-fill-hover);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.voice-toggle:focus-visible {
|
|
22
|
+
outline: var(--frock-focus-ring);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.voice-toggle__state {
|
|
26
|
+
position: absolute;
|
|
27
|
+
right: 2px;
|
|
28
|
+
bottom: 2px;
|
|
29
|
+
width: 8px;
|
|
30
|
+
height: 8px;
|
|
31
|
+
border: 1px solid var(--frock-surface);
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
background: var(--frock-text-subtle);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.voice-toggle--connecting .voice-toggle__state {
|
|
37
|
+
background: var(--frock-warning);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.voice-toggle--listening .voice-toggle__state {
|
|
41
|
+
background: var(--frock-success);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.voice-toggle--speaking .voice-toggle__state {
|
|
45
|
+
background: var(--frock-action-primary);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.voice-surface {
|
|
49
|
+
display: flex;
|
|
50
|
+
min-height: 0;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
gap: 16px;
|
|
53
|
+
padding: 16px 24px 24px;
|
|
54
|
+
color: var(--frock-text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.voice-surface__status {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
60
|
+
gap: 12px;
|
|
61
|
+
align-items: center;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.voice-surface__status h3,
|
|
65
|
+
.voice-surface__status p,
|
|
66
|
+
.voice-surface__message,
|
|
67
|
+
.voice-surface__empty,
|
|
68
|
+
.voice-surface__entry p {
|
|
69
|
+
margin: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.voice-surface__status h3 {
|
|
73
|
+
font-size: var(--frock-text-base);
|
|
74
|
+
text-transform: capitalize;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.voice-surface__status p,
|
|
78
|
+
.voice-surface__empty {
|
|
79
|
+
color: var(--frock-text-muted);
|
|
80
|
+
font-size: var(--frock-text-sm);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.voice-surface__pulse {
|
|
84
|
+
width: 12px;
|
|
85
|
+
height: 12px;
|
|
86
|
+
border-radius: 50%;
|
|
87
|
+
background: var(--frock-text-subtle);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.voice-surface__pulse--connecting {
|
|
91
|
+
background: var(--frock-warning);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.voice-surface__pulse--listening {
|
|
95
|
+
background: var(--frock-success);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.voice-surface__pulse--speaking {
|
|
99
|
+
background: var(--frock-action-primary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.voice-surface__message {
|
|
103
|
+
padding: 10px 12px;
|
|
104
|
+
border-radius: var(--frock-radius-control);
|
|
105
|
+
color: var(--frock-text-muted);
|
|
106
|
+
background: var(--frock-surface-subtle);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.voice-surface__activity {
|
|
110
|
+
display: flex;
|
|
111
|
+
min-height: 0;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 10px;
|
|
114
|
+
overflow-y: auto;
|
|
115
|
+
margin: 0;
|
|
116
|
+
padding: 0;
|
|
117
|
+
list-style: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.voice-surface__entry {
|
|
121
|
+
padding: 10px 12px;
|
|
122
|
+
border-radius: var(--frock-radius-control);
|
|
123
|
+
background: var(--frock-surface-subtle);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.voice-surface__entry > span {
|
|
127
|
+
color: var(--frock-text-subtle);
|
|
128
|
+
font-size: var(--frock-text-xs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.voice-surface__entry p {
|
|
132
|
+
margin-top: 4px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.voice-surface__entry--tool {
|
|
136
|
+
border-left: 2px solid var(--frock-border-strong);
|
|
137
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { VoiceLedgerV1, type VoiceLedgerStorageV1 } from "./ledger.js";
|
|
3
|
+
|
|
4
|
+
function memoryStorage(): VoiceLedgerStorageV1 {
|
|
5
|
+
const values = new Map<string, unknown>();
|
|
6
|
+
const surface = {
|
|
7
|
+
async get<T>(key: string) {
|
|
8
|
+
return values.get(key) as T | undefined;
|
|
9
|
+
},
|
|
10
|
+
async put(key: string, value: unknown) {
|
|
11
|
+
values.set(key, value);
|
|
12
|
+
},
|
|
13
|
+
async delete(key: string) {
|
|
14
|
+
return values.delete(key);
|
|
15
|
+
},
|
|
16
|
+
async list<T>({ prefix, limit }: { prefix: string; limit?: number }) {
|
|
17
|
+
return new Map(
|
|
18
|
+
[...values]
|
|
19
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
20
|
+
.slice(0, limit)
|
|
21
|
+
.map(([key, value]) => [key, value as T]),
|
|
22
|
+
);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
return { ...surface, transaction: (callback) => callback(surface) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const AT = "2026-09-04T01:02:03.000Z";
|
|
29
|
+
|
|
30
|
+
describe("VoiceLedgerV1", () => {
|
|
31
|
+
test("records voice/ask, voice/answered, and voice/briefed in order", async () => {
|
|
32
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
33
|
+
await ledger.recordAsk({
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
type: "voice/ask",
|
|
36
|
+
askId: "ask-1",
|
|
37
|
+
sessionId: "session-1",
|
|
38
|
+
botId: "research",
|
|
39
|
+
botName: "Research",
|
|
40
|
+
question: "What changed?",
|
|
41
|
+
runId: "agent-1",
|
|
42
|
+
askedAt: AT,
|
|
43
|
+
});
|
|
44
|
+
await ledger.recordAnswered({
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
type: "voice/answered",
|
|
47
|
+
askId: "ask-1",
|
|
48
|
+
botId: "research",
|
|
49
|
+
runId: "agent-1",
|
|
50
|
+
answer: "The release landed.",
|
|
51
|
+
answeredAt: "2026-09-04T01:03:03.000Z",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect((await ledger.view()).pendingAnswers).toEqual([
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
answerId: "ask-1",
|
|
57
|
+
question: "What changed?",
|
|
58
|
+
answer: "The release landed.",
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
expect(
|
|
62
|
+
await ledger.markBriefed({
|
|
63
|
+
askIds: ["ask-1", "ask-1"],
|
|
64
|
+
sessionId: "session-2",
|
|
65
|
+
at: "2026-09-04T01:04:03.000Z",
|
|
66
|
+
}),
|
|
67
|
+
).toBe(1);
|
|
68
|
+
expect((await ledger.view()).pendingAnswers).toEqual([]);
|
|
69
|
+
expect(await ledger.readAsk("ask-1")).toEqual(
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
ask: expect.objectContaining({ type: "voice/ask" }),
|
|
72
|
+
answered: expect.objectContaining({ type: "voice/answered" }),
|
|
73
|
+
briefed: expect.objectContaining({
|
|
74
|
+
type: "voice/briefed",
|
|
75
|
+
sessionId: "session-2",
|
|
76
|
+
}),
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("keeps an answer pending when Voice is off", async () => {
|
|
82
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
83
|
+
await ledger.start({ sessionId: "session-1", deviceId: "phone", at: AT });
|
|
84
|
+
await ledger.end({
|
|
85
|
+
sessionId: "session-1",
|
|
86
|
+
at: "2026-09-04T01:02:04.000Z",
|
|
87
|
+
reason: "stopped",
|
|
88
|
+
seconds: 1,
|
|
89
|
+
});
|
|
90
|
+
await ledger.recordAsk({
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
type: "voice/ask",
|
|
93
|
+
askId: "ask-offline",
|
|
94
|
+
sessionId: "session-1",
|
|
95
|
+
botId: "research",
|
|
96
|
+
botName: "Research",
|
|
97
|
+
question: "Are you done?",
|
|
98
|
+
runId: "agent-offline",
|
|
99
|
+
askedAt: AT,
|
|
100
|
+
});
|
|
101
|
+
await ledger.recordAnswered({
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
type: "voice/answered",
|
|
104
|
+
askId: "ask-offline",
|
|
105
|
+
botId: "research",
|
|
106
|
+
runId: "agent-offline",
|
|
107
|
+
answer: "Yes.",
|
|
108
|
+
answeredAt: "2026-09-04T01:03:03.000Z",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const view = await ledger.view();
|
|
112
|
+
expect(view.state.enabled).toBe(false);
|
|
113
|
+
expect(view.pendingAnswers).toEqual([
|
|
114
|
+
expect.objectContaining({ answerId: "ask-offline", answer: "Yes." }),
|
|
115
|
+
]);
|
|
116
|
+
expect((await ledger.readAsk("ask-offline"))?.briefed).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("counts unanswered asks against the pending-answer bound", async () => {
|
|
120
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
121
|
+
for (let index = 0; index < 32; index += 1) {
|
|
122
|
+
await expect(
|
|
123
|
+
ledger.recordAsk({
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
type: "voice/ask",
|
|
126
|
+
askId: `ask-${index}`,
|
|
127
|
+
sessionId: `session-${index}`,
|
|
128
|
+
botId: "research",
|
|
129
|
+
botName: "Research",
|
|
130
|
+
question: "What changed?",
|
|
131
|
+
runId: `agent-${index}`,
|
|
132
|
+
askedAt: AT,
|
|
133
|
+
}),
|
|
134
|
+
).resolves.toMatchObject({ status: "recorded" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
ledger.recordAsk({
|
|
139
|
+
schemaVersion: 1,
|
|
140
|
+
type: "voice/ask",
|
|
141
|
+
askId: "ask-over-cap",
|
|
142
|
+
sessionId: "session-over-cap",
|
|
143
|
+
botId: "research",
|
|
144
|
+
botName: "Research",
|
|
145
|
+
question: "One more?",
|
|
146
|
+
runId: "agent-over-cap",
|
|
147
|
+
askedAt: AT,
|
|
148
|
+
}),
|
|
149
|
+
).resolves.toEqual({
|
|
150
|
+
status: "refused",
|
|
151
|
+
reason: "Voice already has 32 Bot answers waiting or on the way.",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the newest device wins and the previous session ends durably", async () => {
|
|
156
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
157
|
+
await ledger.start({ sessionId: "first", deviceId: "phone", at: AT });
|
|
158
|
+
const next = await ledger.start({
|
|
159
|
+
sessionId: "second",
|
|
160
|
+
deviceId: "laptop",
|
|
161
|
+
at: "2026-09-04T01:03:03.000Z",
|
|
162
|
+
});
|
|
163
|
+
expect(next.replacedSessionId).toBe("first");
|
|
164
|
+
const view = await ledger.view();
|
|
165
|
+
expect(view.state).toMatchObject({
|
|
166
|
+
enabled: true,
|
|
167
|
+
activeSessionId: "second",
|
|
168
|
+
activeDeviceId: "laptop",
|
|
169
|
+
});
|
|
170
|
+
expect(
|
|
171
|
+
view.sessions.find((session) => session.sessionId === "first"),
|
|
172
|
+
).toMatchObject({
|
|
173
|
+
endedReason: "replaced",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("transcript and tool writes are idempotent and never contain audio", async () => {
|
|
178
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
179
|
+
await ledger.start({ sessionId: "one", deviceId: "phone", at: AT });
|
|
180
|
+
const transcript = {
|
|
181
|
+
schemaVersion: 1 as const,
|
|
182
|
+
id: "utterance-1",
|
|
183
|
+
speaker: "user" as const,
|
|
184
|
+
text: "What is happening?",
|
|
185
|
+
at: AT,
|
|
186
|
+
};
|
|
187
|
+
await ledger.appendTranscript("one", transcript);
|
|
188
|
+
await ledger.appendTranscript("one", transcript);
|
|
189
|
+
await ledger.appendToolCall("one", {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
id: "call-1",
|
|
192
|
+
name: "list_bots",
|
|
193
|
+
label: "Checked your Bots",
|
|
194
|
+
at: AT,
|
|
195
|
+
});
|
|
196
|
+
const session = (await ledger.view()).sessions[0]!;
|
|
197
|
+
expect(session.transcript).toEqual([transcript]);
|
|
198
|
+
expect(session.toolCalls).toHaveLength(1);
|
|
199
|
+
expect(JSON.stringify(session)).not.toContain("audio");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("an old device cannot turn the replacement session off", async () => {
|
|
203
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
204
|
+
await ledger.start({ sessionId: "first", deviceId: "phone", at: AT });
|
|
205
|
+
await ledger.start({
|
|
206
|
+
sessionId: "second",
|
|
207
|
+
deviceId: "laptop",
|
|
208
|
+
at: "2026-09-04T01:03:03.000Z",
|
|
209
|
+
});
|
|
210
|
+
const state = await ledger.end({
|
|
211
|
+
sessionId: "first",
|
|
212
|
+
at: "2026-09-04T01:04:03.000Z",
|
|
213
|
+
reason: "stopped",
|
|
214
|
+
seconds: 60,
|
|
215
|
+
});
|
|
216
|
+
expect(state.activeSessionId).toBe("second");
|
|
217
|
+
expect(state.enabled).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("bounds retained sessions and pending answers", async () => {
|
|
221
|
+
const ledger = new VoiceLedgerV1(memoryStorage());
|
|
222
|
+
for (let index = 0; index < 40; index += 1) {
|
|
223
|
+
const suffix = String(index).padStart(2, "0");
|
|
224
|
+
await ledger.start({
|
|
225
|
+
sessionId: `session-${suffix}`,
|
|
226
|
+
deviceId: "phone",
|
|
227
|
+
at: `2026-09-04T01:${suffix}:00.000Z`,
|
|
228
|
+
});
|
|
229
|
+
await ledger.recordPendingAnswer({
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
answerId: `answer-${suffix}`,
|
|
232
|
+
botId: "research",
|
|
233
|
+
botName: "Research",
|
|
234
|
+
question: "What changed?",
|
|
235
|
+
answer: `Answer ${suffix}`,
|
|
236
|
+
answeredAt: `2026-09-04T01:${suffix}:30.000Z`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const view = await ledger.view();
|
|
241
|
+
expect(view.sessions).toHaveLength(24);
|
|
242
|
+
expect(view.sessions.at(-1)?.sessionId).toBe("session-16");
|
|
243
|
+
expect(view.pendingAnswers).toHaveLength(32);
|
|
244
|
+
expect(view.pendingAnswers[0]?.answerId).toBe("answer-08");
|
|
245
|
+
});
|
|
246
|
+
});
|