Skip to content

Commit 756ed4f

Browse files
Start canonical Voice speech before settlement
Amp-Thread-ID: https://ampcode.com/threads/T-01a06d67-bcd6-77dc-b3c4-4b51537a64c8 Co-authored-by: Amp <amp@ampcode.com>
1 parent a1fb27c commit 756ed4f

11 files changed

Lines changed: 763 additions & 32 deletions

File tree

apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ test("delegates one typed message to the supplied Flue conversation", async () =
2525
turnId: "turn-1",
2626
position: { batch: 1, index: 0 },
2727
});
28+
await options?.onEvent?.({
29+
type: "message-completed",
30+
conversationId: "conversation-stable",
31+
messageId: "assistant-1",
32+
position: { batch: 1, index: 1 },
33+
});
2834
await options?.onEvent?.({
2935
type: "submission-settled",
3036
conversationId: "conversation-stable",
3137
submissionId: admission.submissionId,
3238
outcome: "completed",
33-
position: { batch: 1, index: 1 },
39+
position: { batch: 1, index: 2 },
3440
});
3541
});
3642
const client = {
@@ -43,6 +49,10 @@ test("delegates one typed message to the supplied Flue conversation", async () =
4349
{ kind: "user", messageId: "user-1" },
4450
admissionListener,
4551
);
52+
const responseCompletedListener = vi.fn();
53+
tracker.subscribeToResponseMessageCompleted(responseCompletedListener);
54+
const responseStartedListener = vi.fn();
55+
tracker.subscribeToResponseMessageStarted(responseStartedListener);
4656
const onAdmission = vi.fn();
4757
const transport = createBrunchPanelTransport(
4858
Promise.resolve(client),
@@ -80,6 +90,18 @@ test("delegates one typed message to the supplied Flue conversation", async () =
8090
expect(tracker.submissionsForResponse("assistant-1")).toEqual([
8191
"submission-1",
8292
]);
93+
expect(responseStartedListener).toHaveBeenCalledOnce();
94+
expect(responseStartedListener).toHaveBeenCalledWith({
95+
messageId: "assistant-1",
96+
position: { batch: 1, index: 0 },
97+
submissionId: "submission-1",
98+
});
99+
expect(responseCompletedListener).toHaveBeenCalledOnce();
100+
expect(responseCompletedListener).toHaveBeenCalledWith({
101+
messageId: "assistant-1",
102+
position: { batch: 1, index: 1 },
103+
submissionId: "submission-1",
104+
});
83105
expect(onAdmission).toHaveBeenCalledOnce();
84106
expect(onAdmission).toHaveBeenCalledWith(admission);
85107
});
@@ -141,15 +163,66 @@ test("matches client-tool admissions once and supports unsubscribe", () => {
141163

142164
test("records every submission that wrote a resumed assistant message", () => {
143165
const tracker = new BrunchPanelConversationTracker();
144-
tracker.recordResponse("assistant-1", "submission-1");
145-
tracker.recordResponse("assistant-1", "submission-continuation");
146-
tracker.recordResponse("assistant-1", "submission-continuation");
166+
const responseStartedListener = vi.fn();
167+
tracker.subscribeToResponseMessageStarted(responseStartedListener);
168+
tracker.recordResponse({
169+
messageId: "assistant-1",
170+
position: { batch: 1, index: 0 },
171+
submissionId: "submission-1",
172+
});
173+
tracker.recordResponse({
174+
messageId: "assistant-1",
175+
position: { batch: 2, index: 0 },
176+
submissionId: "submission-continuation",
177+
});
178+
tracker.recordResponse({
179+
messageId: "assistant-1",
180+
position: { batch: 2, index: 0 },
181+
submissionId: "submission-continuation",
182+
});
147183

148184
expect(tracker.submissionsForResponse("assistant-1")).toEqual([
149185
"submission-1",
150186
"submission-continuation",
151187
]);
152188
expect(tracker.submissionsForResponse("assistant-2")).toBeUndefined();
189+
expect(responseStartedListener.mock.calls).toEqual([
190+
[
191+
{
192+
messageId: "assistant-1",
193+
position: { batch: 1, index: 0 },
194+
submissionId: "submission-1",
195+
},
196+
],
197+
[
198+
{
199+
messageId: "assistant-1",
200+
position: { batch: 2, index: 0 },
201+
submissionId: "submission-continuation",
202+
},
203+
],
204+
[
205+
{
206+
messageId: "assistant-1",
207+
position: { batch: 2, index: 0 },
208+
submissionId: "submission-continuation",
209+
},
210+
],
211+
]);
212+
});
213+
214+
test("publishes Stop immediately and supports unsubscribe", () => {
215+
const tracker = new BrunchPanelConversationTracker();
216+
const listener = vi.fn();
217+
const unsubscribedListener = vi.fn();
218+
tracker.subscribeToStopRequested(listener);
219+
const unsubscribe = tracker.subscribeToStopRequested(unsubscribedListener);
220+
unsubscribe();
221+
222+
tracker.recordStopRequested();
223+
224+
expect(listener).toHaveBeenCalledOnce();
225+
expect(unsubscribedListener).not.toHaveBeenCalled();
153226
});
154227

155228
test("settles in-flight submissions before a durable abort can target them", async () => {

apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import type {
1414
SweepCompletionReport,
1515
} from "../brunch-sweep-output";
1616
import type { AgentSendResult, FlueClient } from "@flue/sdk";
17-
import type { FlueChatTransportOptions } from "@hashintel/brunch-agent-transport-aisdk";
17+
import type {
18+
FlueChatResponseMessageCompletedEvent,
19+
FlueChatResponseMessageStartedEvent,
20+
FlueChatTransportOptions,
21+
} from "@hashintel/brunch-agent-transport-aisdk";
1822
import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui";
1923
import type { UIMessageChunk } from "ai";
2024

@@ -44,6 +48,13 @@ export class BrunchPanelConversationTracker {
4448
string,
4549
AgentSendResult["submissionId"][]
4650
>();
51+
readonly #responseMessageStartedListeners = new Set<
52+
(event: FlueChatResponseMessageStartedEvent) => void
53+
>();
54+
readonly #responseMessageCompletedListeners = new Set<
55+
(event: FlueChatResponseMessageCompletedEvent) => void
56+
>();
57+
readonly #stopRequestedListeners = new Set<() => void>();
4758

4859
public recordAdmission(admission: BrunchPanelAdmission): void {
4960
if (admission.kind === "user") {
@@ -69,15 +80,29 @@ export class BrunchPanelConversationTracker {
6980
* all: Voice correlates a reply by membership, whichever side admitted the
7081
* continuation.
7182
*/
72-
public recordResponse(
73-
messageId: string,
74-
submissionId: AgentSendResult["submissionId"],
75-
): void {
76-
const recorded = this.#responseSubmissions.get(messageId);
83+
public recordResponse(event: FlueChatResponseMessageStartedEvent): void {
84+
const recorded = this.#responseSubmissions.get(event.messageId);
7785
if (recorded === undefined) {
78-
this.#responseSubmissions.set(messageId, [submissionId]);
79-
} else if (!recorded.includes(submissionId)) {
80-
recorded.push(submissionId);
86+
this.#responseSubmissions.set(event.messageId, [event.submissionId]);
87+
} else if (!recorded.includes(event.submissionId)) {
88+
recorded.push(event.submissionId);
89+
}
90+
for (const listener of this.#responseMessageStartedListeners) {
91+
listener(event);
92+
}
93+
}
94+
95+
public recordResponseMessageCompleted(
96+
event: FlueChatResponseMessageCompletedEvent,
97+
): void {
98+
for (const listener of this.#responseMessageCompletedListeners) {
99+
listener(event);
100+
}
101+
}
102+
103+
public recordStopRequested(): void {
104+
for (const listener of this.#stopRequestedListeners) {
105+
listener();
81106
}
82107
}
83108

@@ -143,6 +168,25 @@ export class BrunchPanelConversationTracker {
143168
this.#admissionFailureSubscriptions.add(subscription);
144169
return () => this.#admissionFailureSubscriptions.delete(subscription);
145170
}
171+
172+
public subscribeToResponseMessageCompleted(
173+
listener: (event: FlueChatResponseMessageCompletedEvent) => void,
174+
): () => void {
175+
this.#responseMessageCompletedListeners.add(listener);
176+
return () => this.#responseMessageCompletedListeners.delete(listener);
177+
}
178+
179+
public subscribeToResponseMessageStarted(
180+
listener: (event: FlueChatResponseMessageStartedEvent) => void,
181+
): () => void {
182+
this.#responseMessageStartedListeners.add(listener);
183+
return () => this.#responseMessageStartedListeners.delete(listener);
184+
}
185+
186+
public subscribeToStopRequested(listener: () => void): () => void {
187+
this.#stopRequestedListeners.add(listener);
188+
return () => this.#stopRequestedListeners.delete(listener);
189+
}
146190
}
147191

148192
const formatFailure = (failure: SweepCompletionFailure): string => {
@@ -282,8 +326,9 @@ export const createBrunchPanelTransport = (
282326
tracker.recordAdmission(event);
283327
hooks?.onAdmission?.(event.admission);
284328
},
285-
onResponseMessage: ({ messageId, submissionId }) =>
286-
tracker.recordResponse(messageId, submissionId),
329+
onResponseMessage: (event) => tracker.recordResponse(event),
330+
onResponseMessageCompleted: (event) =>
331+
tracker.recordResponseMessageCompleted(event),
287332
});
288333
try {
289334
return decorateBrunchStream(

apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,26 +106,61 @@ describe("local storage demo Brunch voice integration", () => {
106106
throw new Error("Expected the configured composer control to render.");
107107
}
108108
const failureListener = vi.fn();
109+
const responseCompletedListener = vi.fn();
110+
const responseStartedListener = vi.fn();
111+
const stopListener = vi.fn();
109112
const target = { kind: "user" as const, messageId: "voice-turn-1" };
110113
const controlProps = control.props as {
111114
config: typeof config;
112115
subscribeToAdmissionFailure: (
113116
admissionTarget: typeof target,
114117
listener: (error: FlueChatAdmissionError) => void,
115118
) => () => void;
119+
subscribeToResponseMessageCompleted: (
120+
listener: typeof responseCompletedListener,
121+
) => () => void;
122+
subscribeToResponseMessageStarted: (
123+
listener: typeof responseStartedListener,
124+
) => () => void;
125+
subscribeToStopRequested: (listener: () => void) => () => void;
116126
};
117127
expect(control.type).toBe(VoiceInterviewControl);
118128
expect(controlProps.config).toBe(config);
119129
const unsubscribe = controlProps.subscribeToAdmissionFailure(
120130
target,
121131
failureListener,
122132
);
133+
const unsubscribeFromStop =
134+
controlProps.subscribeToStopRequested(stopListener);
135+
const unsubscribeFromResponseCompleted =
136+
controlProps.subscribeToResponseMessageCompleted(
137+
responseCompletedListener,
138+
);
139+
const unsubscribeFromResponseStarted =
140+
controlProps.subscribeToResponseMessageStarted(responseStartedListener);
123141
const admissionError = new FlueChatAdmissionError({ kind: "ambiguous" });
124142

125143
tracker.recordAdmissionFailure(target, admissionError);
144+
tracker.recordResponse({
145+
messageId: "assistant-1",
146+
position: { batch: 1, index: 0 },
147+
submissionId: "submission-1",
148+
});
149+
tracker.recordResponseMessageCompleted({
150+
messageId: "assistant-1",
151+
position: { batch: 1, index: 1 },
152+
submissionId: "submission-1",
153+
});
154+
tracker.recordStopRequested();
126155

127156
expect(failureListener).toHaveBeenCalledWith(admissionError);
157+
expect(responseStartedListener).toHaveBeenCalledOnce();
158+
expect(responseCompletedListener).toHaveBeenCalledOnce();
159+
expect(stopListener).toHaveBeenCalledOnce();
128160
unsubscribe();
161+
unsubscribeFromResponseCompleted();
162+
unsubscribeFromResponseStarted();
163+
unsubscribeFromStop();
129164
});
130165

131166
test("registers no brunch_ask tool in the production Brunch preview", async () => {
@@ -271,6 +306,8 @@ describe("local storage demo Brunch voice integration", () => {
271306
const abort = vi.fn<FlueClient["abort"]>(async () => ({ aborted: true }));
272307
const client = { abort } as Pick<FlueClient, "abort"> as FlueClient;
273308
const tracker = new BrunchPanelConversationTracker();
309+
const stopListener = vi.fn();
310+
tracker.subscribeToStopRequested(stopListener);
274311
let admit: (() => void) | undefined;
275312
void tracker.trackSubmission(
276313
new Promise<void>((resolve) => {
@@ -279,6 +316,7 @@ describe("local storage demo Brunch voice integration", () => {
279316
);
280317

281318
const stop = requestFlueStop(Promise.resolve(client), tracker);
319+
expect(stopListener).toHaveBeenCalledOnce();
282320
await Promise.resolve();
283321
await Promise.resolve();
284322
expect(abort).not.toHaveBeenCalled();

apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ export const getBrunchVoiceMode = (
130130
resolveResponseSubmission={(messageId) =>
131131
tracker?.submissionsForResponse(messageId)
132132
}
133+
subscribeToResponseMessageCompleted={
134+
tracker === undefined
135+
? undefined
136+
: (listener) =>
137+
tracker.subscribeToResponseMessageCompleted(listener)
138+
}
139+
subscribeToResponseMessageStarted={
140+
tracker === undefined
141+
? undefined
142+
: (listener) =>
143+
tracker.subscribeToResponseMessageStarted(listener)
144+
}
145+
subscribeToStopRequested={
146+
tracker === undefined
147+
? undefined
148+
: (listener) => tracker.subscribeToStopRequested(listener)
149+
}
133150
subscribeToAdmission={
134151
tracker === undefined
135152
? undefined
@@ -188,6 +205,7 @@ export const requestFlueStop = async (
188205
clientPromise: Promise<ReturnType<typeof createFlueClient>>,
189206
tracker: BrunchPanelConversationTracker,
190207
): Promise<PetrinautAiStopResult> => {
208+
tracker.recordStopRequested();
191209
const client = await clientPromise;
192210
await tracker.settleInFlightSubmissions();
193211
const result = await client.abort();

0 commit comments

Comments
 (0)