@runtypelabs/persona 4.26.1 → 4.28.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/dist/approval-ui.d.cts +18 -0
- package/dist/approval-ui.d.ts +18 -0
- package/dist/artifacts-ui.d.cts +18 -0
- package/dist/artifacts-ui.d.ts +18 -0
- package/dist/chunk-AALCT54S.js +1 -0
- package/dist/{client-history-GOZWBDA5.js → client-history-LV3KHEFA.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream-N22JHTLE.js +4 -0
- package/dist/client-stream.js +3 -3
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +18 -0
- package/dist/context-mentions.d.ts +18 -0
- package/dist/event-stream-view.d.cts +18 -0
- package/dist/event-stream-view.d.ts +18 -0
- package/dist/forms-ui.d.cts +26 -0
- package/dist/forms-ui.d.ts +26 -0
- package/dist/index.cjs +57 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +58 -58
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +18 -0
- package/dist/session-reconnect.d.ts +18 -0
- package/dist/smart-dom-reader.d.cts +18 -0
- package/dist/smart-dom-reader.d.ts +18 -0
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +18 -0
- package/dist/theme-editor-preview.d.ts +18 -0
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +18 -0
- package/dist/theme-editor.d.ts +18 -0
- package/dist/voice-runtime-LVUMPUDS.js +17 -0
- package/dist/voice-runtime.cjs +7 -7
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.js +7 -7
- package/package.json +1 -1
- package/src/client-stream.ts +10 -3
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.test.ts +20 -0
- package/src/client.ts +7 -0
- package/src/session.ts +16 -3
- package/src/session.voice.test.ts +199 -3
- package/src/types.ts +24 -0
- package/src/ui.composer-defer-one.test.ts +17 -0
- package/src/ui.ts +49 -12
- package/src/voice/__fixtures__/voice-wire/server/session_end-provider-error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/session_end-unknown-reason.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/session_end.json +5 -0
- package/src/voice/runtype-voice-provider.ts +205 -21
- package/src/voice/voice-clause-buffer.test.ts +143 -0
- package/src/voice/voice-clause-buffer.ts +187 -0
- package/src/voice/voice-delegation.ts +111 -13
- package/src/voice/voice.test.ts +433 -2
- package/dist/chunk-GKPCIQPB.js +0 -1
- package/dist/client-stream-42FOI3EC.js +0 -4
- package/dist/voice-runtime-XLU6YK2N.js +0 -17
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groups a growing answer into speakable pieces for `delegation_delta`
|
|
3
|
+
* (streaming client delegation): the first piece goes out early (at a clause
|
|
4
|
+
* mark, or once ~30 characters / 6 words are in), later pieces at sentence ends
|
|
5
|
+
* or line breaks, and no piece is longer than {@link CLAUSE_MAX_CHARS}.
|
|
6
|
+
*
|
|
7
|
+
* Pieces are exact slices of the input: concatenated, they equal everything
|
|
8
|
+
* pushed so far (whitespace included), so a receiver can count them as a
|
|
9
|
+
* prefix of the final text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The longest piece. */
|
|
13
|
+
export const CLAUSE_MAX_CHARS = 220;
|
|
14
|
+
const FIRST_MIN_CHARS = 30;
|
|
15
|
+
const FIRST_MIN_WORDS = 6;
|
|
16
|
+
|
|
17
|
+
/** Words ending in "." that rarely end a sentence (lowercased, inner dots kept). */
|
|
18
|
+
const ABBREVIATIONS = new Set([
|
|
19
|
+
"mr",
|
|
20
|
+
"mrs",
|
|
21
|
+
"ms",
|
|
22
|
+
"dr",
|
|
23
|
+
"prof",
|
|
24
|
+
"sr",
|
|
25
|
+
"jr",
|
|
26
|
+
"st",
|
|
27
|
+
"mt",
|
|
28
|
+
"vs",
|
|
29
|
+
"etc",
|
|
30
|
+
"approx",
|
|
31
|
+
"inc",
|
|
32
|
+
"ltd",
|
|
33
|
+
"co",
|
|
34
|
+
"corp",
|
|
35
|
+
"no",
|
|
36
|
+
"nos",
|
|
37
|
+
"fig",
|
|
38
|
+
"ft",
|
|
39
|
+
"dept",
|
|
40
|
+
"est",
|
|
41
|
+
"min",
|
|
42
|
+
"max",
|
|
43
|
+
"e.g",
|
|
44
|
+
"i.e",
|
|
45
|
+
"a.m",
|
|
46
|
+
"p.m",
|
|
47
|
+
"u.s",
|
|
48
|
+
"u.k",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Punctuation (then closers like quotes, brackets or Markdown emphasis) and the
|
|
52
|
+
// whitespace after it, or a line break.
|
|
53
|
+
const BOUNDARY = /([.!?]+|[,;:])(["'”’)\]*_]*)(\s+)|\n\s*/g;
|
|
54
|
+
|
|
55
|
+
/** Whether the "." ending `before` belongs to an abbreviation, an initial, or a list number. */
|
|
56
|
+
function dotIsNotSentenceEnd(before: string): boolean {
|
|
57
|
+
const token = /(\S+)$/.exec(before)?.[1];
|
|
58
|
+
if (!token) return false;
|
|
59
|
+
const word = token.replace(/^[("'“‘[*_]+/, "").toLowerCase();
|
|
60
|
+
if (ABBREVIATIONS.has(word)) return true;
|
|
61
|
+
// An initial ("J.") or a dotted abbreviation ("U.S", "e.g").
|
|
62
|
+
if (/^[a-z](?:\.[a-z])*$/.test(word)) return true;
|
|
63
|
+
// "1. " opening a line is a list marker, not a sentence.
|
|
64
|
+
if (/^\d+$/.test(token)) {
|
|
65
|
+
const start = before.length - token.length;
|
|
66
|
+
return start === 0 || /\n\s*$/.test(before.slice(0, start));
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ends (after the whitespace) of each boundary in `text` with content before
|
|
73
|
+
* it. A boundary whose whitespace reaches the end of `text` may still grow
|
|
74
|
+
* ("\n" then "\n- item"), so it counts only once more text follows.
|
|
75
|
+
*/
|
|
76
|
+
function* boundaryEnds(text: string, clauses: boolean): Generator<number> {
|
|
77
|
+
const re = new RegExp(BOUNDARY.source, "g");
|
|
78
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
79
|
+
const mark = m[1];
|
|
80
|
+
if (!text.slice(0, m.index + (mark?.length ?? 0)).trim()) continue;
|
|
81
|
+
if (mark !== undefined) {
|
|
82
|
+
if (/^[,;:]$/.test(mark) && !clauses) continue;
|
|
83
|
+
if (mark === "." && dotIsNotSentenceEnd(text.slice(0, m.index))) continue;
|
|
84
|
+
}
|
|
85
|
+
const end = m.index + m[0].length;
|
|
86
|
+
if (end < text.length) yield end;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** End of the last whitespace run within `limit` that has content before it, or 0. */
|
|
91
|
+
function lastWordEnd(text: string, limit: number): number {
|
|
92
|
+
const window = text.slice(0, limit);
|
|
93
|
+
let best = 0;
|
|
94
|
+
for (const m of window.matchAll(/\s+/g)) {
|
|
95
|
+
if (window.slice(0, m.index).trim()) best = m.index! + m[0].length;
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where to cut a piece that reached {@link CLAUSE_MAX_CHARS} with no sentence end. */
|
|
101
|
+
function overflowCut(text: string): number {
|
|
102
|
+
let clause = 0;
|
|
103
|
+
for (const end of boundaryEnds(text.slice(0, CLAUSE_MAX_CHARS), true)) clause = end;
|
|
104
|
+
if (clause) return clause;
|
|
105
|
+
const word = lastWordEnd(text, CLAUSE_MAX_CHARS);
|
|
106
|
+
if (word) return word;
|
|
107
|
+
// One unbroken run: cut hard, never inside a surrogate pair.
|
|
108
|
+
const code = text.charCodeAt(CLAUSE_MAX_CHARS - 1);
|
|
109
|
+
return code >= 0xd800 && code <= 0xdbff ? CLAUSE_MAX_CHARS - 1 : CLAUSE_MAX_CHARS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ClauseBuffer {
|
|
113
|
+
#pending = "";
|
|
114
|
+
#first = true;
|
|
115
|
+
|
|
116
|
+
/** Add text; returns the pieces now complete (possibly none). */
|
|
117
|
+
push(text: string): string[] {
|
|
118
|
+
this.#pending += text;
|
|
119
|
+
return this.#drain();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Everything still held, as the last pieces (whitespace-only remainder is dropped). */
|
|
123
|
+
flush(): string[] {
|
|
124
|
+
const pieces = this.#drain();
|
|
125
|
+
const rest = this.#pending;
|
|
126
|
+
this.#pending = "";
|
|
127
|
+
if (rest.trim()) {
|
|
128
|
+
for (let at = 0; at < rest.length; ) {
|
|
129
|
+
const tail = rest.slice(at);
|
|
130
|
+
const cut = tail.length > CLAUSE_MAX_CHARS ? overflowCut(tail) : tail.length;
|
|
131
|
+
pieces.push(tail.slice(0, cut));
|
|
132
|
+
at += cut;
|
|
133
|
+
}
|
|
134
|
+
this.#first = false;
|
|
135
|
+
}
|
|
136
|
+
return pieces;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The stream went quiet: if the held text ends in a sentence terminator
|
|
141
|
+
* (`. ! ? …`, closers allowed) or a line break, release it as if the
|
|
142
|
+
* boundary were confirmed. No abbreviation guards: a pause is enough.
|
|
143
|
+
*/
|
|
144
|
+
idle(): string[] {
|
|
145
|
+
const text = this.#pending;
|
|
146
|
+
if (!text.trim() || !/(?:[.!?…]["'”’)\]*_]*|\n)\s*$/.test(text)) return [];
|
|
147
|
+
this.#pending = "";
|
|
148
|
+
this.#first = false;
|
|
149
|
+
const pieces: string[] = [];
|
|
150
|
+
for (let at = 0; at < text.length; ) {
|
|
151
|
+
const tail = text.slice(at);
|
|
152
|
+
const cut = tail.length > CLAUSE_MAX_CHARS ? overflowCut(tail) : tail.length;
|
|
153
|
+
pieces.push(tail.slice(0, cut));
|
|
154
|
+
at += cut;
|
|
155
|
+
}
|
|
156
|
+
return pieces;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#drain(): string[] {
|
|
160
|
+
const pieces: string[] = [];
|
|
161
|
+
for (let cut = this.#cut(); cut > 0; cut = this.#cut()) {
|
|
162
|
+
pieces.push(this.#pending.slice(0, cut));
|
|
163
|
+
this.#pending = this.#pending.slice(cut);
|
|
164
|
+
this.#first = false;
|
|
165
|
+
}
|
|
166
|
+
return pieces;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Length of the next complete piece, or 0 to keep waiting. */
|
|
170
|
+
#cut(): number {
|
|
171
|
+
const text = this.#pending;
|
|
172
|
+
if (!text.trim()) return 0;
|
|
173
|
+
const end = boundaryEnds(text, this.#first).next().value ?? 0;
|
|
174
|
+
if (end && end <= CLAUSE_MAX_CHARS) return end;
|
|
175
|
+
// Ending on sentence punctuation, the text is most likely a whole sentence
|
|
176
|
+
// waiting for the space that confirms it: don't split it early.
|
|
177
|
+
const atSentenceEnd = /[.!?]["'”’)\]*_]*$/.test(text);
|
|
178
|
+
if (this.#first && !atSentenceEnd) {
|
|
179
|
+
const words = text.match(/\S+\s+/g)?.length ?? 0;
|
|
180
|
+
if (text.trim().length >= FIRST_MIN_CHARS || words >= FIRST_MIN_WORDS) {
|
|
181
|
+
const word = lastWordEnd(text, CLAUSE_MAX_CHARS);
|
|
182
|
+
if (word) return word;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return text.length > CLAUSE_MAX_CHARS ? overflowCut(text) : 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -7,11 +7,32 @@ import type {
|
|
|
7
7
|
VoiceSessionBridge,
|
|
8
8
|
} from "../types";
|
|
9
9
|
|
|
10
|
+
import { ClauseBuffer } from "./voice-clause-buffer";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* What a delegated turn streamed: its assistant messages, whether it failed,
|
|
12
14
|
* and whether the server dropped its result (then browser TTS may read it).
|
|
15
|
+
* `onUpdate` runs after each of its assistant messages is stored (the answer
|
|
16
|
+
* grew), so a streaming bridge can read the new text.
|
|
13
17
|
*/
|
|
14
|
-
export type VoiceDelegationCapture = {
|
|
18
|
+
export type VoiceDelegationCapture = {
|
|
19
|
+
ids: string[];
|
|
20
|
+
failed: boolean;
|
|
21
|
+
dropped?: boolean;
|
|
22
|
+
onUpdate?: () => void;
|
|
23
|
+
/** Stops a streaming answer (its pending idle flush included); set while it streams. */
|
|
24
|
+
stopStream?: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** How long a held sentence end waits for more text before it streams anyway. */
|
|
28
|
+
const STREAM_IDLE_MS = 200;
|
|
29
|
+
|
|
30
|
+
/** Length of the common prefix of `a` and `b`. */
|
|
31
|
+
const commonPrefix = (a: string, b: string) => {
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
|
|
34
|
+
return i;
|
|
35
|
+
};
|
|
15
36
|
|
|
16
37
|
/** The session internals the delegation bridge drives. */
|
|
17
38
|
export interface VoiceDelegationHost {
|
|
@@ -164,16 +185,32 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
164
185
|
};
|
|
165
186
|
|
|
166
187
|
return {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
// Read once at call start, before this call has any captions, so the
|
|
189
|
+
// captions here are earlier calls' (e.g. on the previous page of a
|
|
190
|
+
// multi-page site): spoken conversation the new voice session never heard.
|
|
191
|
+
// An assistant caption followed by a chat answer before the next user turn
|
|
192
|
+
// is a delegated turn's spoken filler ("Let me check."): it would only use
|
|
193
|
+
// up a slot of the context's recent-message window.
|
|
194
|
+
getHistory: () => {
|
|
195
|
+
const settled = host.messages().filter(
|
|
196
|
+
(m) =>
|
|
197
|
+
(m.role === "user" || m.role === "assistant") &&
|
|
198
|
+
!m.variant &&
|
|
199
|
+
!m.voiceProcessing &&
|
|
200
|
+
!m.streaming &&
|
|
201
|
+
m.content,
|
|
202
|
+
);
|
|
203
|
+
let answered = false;
|
|
204
|
+
const kept: Array<{ role: "user" | "assistant"; content: string }> = [];
|
|
205
|
+
for (let i = settled.length - 1; i >= 0; i -= 1) {
|
|
206
|
+
const m = settled[i];
|
|
207
|
+
if (m.role === "user") answered = false;
|
|
208
|
+
else if (!m.voiceCaption) answered = true;
|
|
209
|
+
else if (answered) continue;
|
|
210
|
+
kept.unshift({ role: m.role as "user" | "assistant", content: m.content });
|
|
211
|
+
}
|
|
212
|
+
return kept;
|
|
213
|
+
},
|
|
177
214
|
|
|
178
215
|
dropDelegation(delegationId, expired) {
|
|
179
216
|
// Remembered for a turn still running: when it parks, the same rule applies.
|
|
@@ -183,6 +220,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
183
220
|
const park = expired && parks.find((p) => p.delegationId === delegationId);
|
|
184
221
|
if (park && !park.outcome) settle(park, "timeout", "");
|
|
185
222
|
for (const capture of captures.get(delegationId) ?? []) {
|
|
223
|
+
capture.stopStream?.();
|
|
186
224
|
capture.dropped = true;
|
|
187
225
|
if (capture.ids.length) host.unspoken(capture.ids);
|
|
188
226
|
}
|
|
@@ -192,6 +230,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
192
230
|
delegationId,
|
|
193
231
|
userText,
|
|
194
232
|
userUtteranceIds,
|
|
233
|
+
stream,
|
|
195
234
|
}: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
|
|
196
235
|
const live = parks.filter((park) => pendingOf(park).length);
|
|
197
236
|
const pendingIds = live.flatMap(pendingOf);
|
|
@@ -212,6 +251,48 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
212
251
|
const before = new Set(host.messages().map((m) => m.id));
|
|
213
252
|
const raised = () => host.messages().filter((m) => !before.has(m.id) && m.variant === "approval");
|
|
214
253
|
const capture: VoiceDelegationCapture = { ids: [], failed: false };
|
|
254
|
+
// Streaming (`delegation_stream`): the answer goes out in speakable
|
|
255
|
+
// pieces while it renders. `fed` is the answer text the buffer has seen,
|
|
256
|
+
// `sent` what went out. An answer rewritten mid-stream (not an extension
|
|
257
|
+
// of what was fed) stops the stream; the result then says how much of
|
|
258
|
+
// its text was already sent.
|
|
259
|
+
const buffer = stream ? new ClauseBuffer() : null;
|
|
260
|
+
let fed = "";
|
|
261
|
+
let sent = "";
|
|
262
|
+
let rewritten = false;
|
|
263
|
+
const emit = (pieces: string[]) => {
|
|
264
|
+
for (const piece of pieces) {
|
|
265
|
+
sent += piece;
|
|
266
|
+
stream!(piece);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
// A sentence end is confirmed only by the text after it; when the
|
|
270
|
+
// stream pauses on one, it goes out after STREAM_IDLE_MS anyway.
|
|
271
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
272
|
+
if (buffer) {
|
|
273
|
+
capture.onUpdate = () => {
|
|
274
|
+
if (rewritten) return;
|
|
275
|
+
const now = answerOf(capture.ids);
|
|
276
|
+
if (!now.startsWith(fed)) {
|
|
277
|
+
rewritten = true;
|
|
278
|
+
clearTimeout(idleTimer);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (now.length === fed.length) return;
|
|
282
|
+
const delta = now.slice(fed.length);
|
|
283
|
+
fed = now;
|
|
284
|
+
emit(buffer.push(delta));
|
|
285
|
+
clearTimeout(idleTimer);
|
|
286
|
+
idleTimer = setTimeout(() => {
|
|
287
|
+
if (!rewritten) emit(buffer.idle());
|
|
288
|
+
}, STREAM_IDLE_MS);
|
|
289
|
+
};
|
|
290
|
+
capture.stopStream = () => {
|
|
291
|
+
clearTimeout(idleTimer);
|
|
292
|
+
rewritten = true; // nothing more streams
|
|
293
|
+
capture.onUpdate = undefined;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
215
296
|
remember(delegationId, capture);
|
|
216
297
|
active = capture;
|
|
217
298
|
retrack();
|
|
@@ -225,6 +306,19 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
225
306
|
active = null;
|
|
226
307
|
retrack();
|
|
227
308
|
}
|
|
309
|
+
if (buffer) {
|
|
310
|
+
// The last of the answer goes out before the result.
|
|
311
|
+
capture.onUpdate?.();
|
|
312
|
+
clearTimeout(idleTimer);
|
|
313
|
+
if (!rewritten) emit(buffer.flush());
|
|
314
|
+
capture.onUpdate = undefined;
|
|
315
|
+
capture.stopStream = undefined;
|
|
316
|
+
}
|
|
317
|
+
/** `streamedChars` for a result whose `text` starts with the answer. */
|
|
318
|
+
const streamed = (text: string) => {
|
|
319
|
+
const chars = commonPrefix(sent, text);
|
|
320
|
+
return chars > 0 ? { streamedChars: chars } : {};
|
|
321
|
+
};
|
|
228
322
|
const replies = capture.ids.flatMap((id) => host.messages().find((m) => m.id === id) ?? []);
|
|
229
323
|
let text = answerOf(capture.ids);
|
|
230
324
|
const pending = capture.failed ? [] : raised().filter(isPendingApproval);
|
|
@@ -256,10 +350,13 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
256
350
|
if (cancelled.get(delegationId)) settle(park, "timeout", "");
|
|
257
351
|
// Parked on approvals: answer now, so the voice model asks for the
|
|
258
352
|
// decision, then read the outcome back once the visitor decides.
|
|
353
|
+
// The streamed answer stays the prefix; the approval script is the
|
|
354
|
+
// unstreamed suffix.
|
|
259
355
|
text = `${text}\n\n${buildApprovalScript(pending.map((m) => m.approval!))}`.trim();
|
|
260
356
|
return {
|
|
261
357
|
status: "pending_approval",
|
|
262
358
|
text,
|
|
359
|
+
...streamed(text),
|
|
263
360
|
followUp: async ({ signal, approvalTimeoutMs = APPROVAL_TTL_MS, readBack = true, onUpdate }) => {
|
|
264
361
|
const follow: VoiceDelegationCapture = { ids: [], failed: false };
|
|
265
362
|
// Called once the ask went out: it carried this turn's reply to the
|
|
@@ -356,9 +453,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
356
453
|
!m.toolCall?.name?.startsWith("webmcp:"),
|
|
357
454
|
)
|
|
358
455
|
) {
|
|
359
|
-
|
|
456
|
+
text = `${text}\n\n${WAITING_FOR_INPUT}`.trim();
|
|
457
|
+
return { status: capture.failed ? "failed" : "completed", text, ...streamed(text) };
|
|
360
458
|
}
|
|
361
|
-
return { status: !capture.failed && text ? "completed" : "failed", text };
|
|
459
|
+
return { status: !capture.failed && text ? "completed" : "failed", text, ...streamed(text) };
|
|
362
460
|
},
|
|
363
461
|
};
|
|
364
462
|
}
|