Skip to content

Commit 6be2e65

Browse files
committed
fix(server): hydrate legacy voice transcripts
1 parent 8de71c0 commit 6be2e65

6 files changed

Lines changed: 404 additions & 119 deletions

File tree

apps/server/src/orchestration/clientCompatibility.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@ import { describe, expect, it } from "vite-plus/test";
22
import {
33
EventId,
44
MessageId,
5+
ProjectId,
6+
ProviderInstanceId,
57
ThreadId,
8+
TurnId,
69
type OrchestrationMessage,
10+
type OrchestrationThread,
11+
type OrchestrationThreadActivity,
712
type ThreadMessageSentPayload,
813
} from "@t3tools/contracts";
914

1015
import {
16+
makeOrchestrationThreadStreamProjectorForClient,
1117
projectOrchestrationEventForClient,
1218
projectOrchestrationMessageForClient,
19+
projectOrchestrationThreadForClient,
1320
} from "./clientCompatibility.ts";
1421

1522
const audioAttachment = {
@@ -33,6 +40,55 @@ const message: OrchestrationMessage = {
3340
updatedAt: "2026-07-24T12:00:00.000Z",
3441
};
3542

43+
function transcriptionActivity(
44+
status: "transcribing" | "ready" | "failed",
45+
transcript?: string,
46+
): OrchestrationThreadActivity {
47+
return {
48+
id: EventId.make(`activity-${status}`),
49+
tone: status === "failed" ? "error" : "info",
50+
kind: "voice-transcription.updated",
51+
summary: "Voice transcription updated",
52+
payload: {
53+
attachmentId: audioAttachment.id,
54+
status,
55+
...(transcript !== undefined ? { transcript } : {}),
56+
},
57+
turnId: TurnId.make("turn-1"),
58+
createdAt: "2026-07-24T12:00:01.000Z",
59+
};
60+
}
61+
62+
function threadWithActivities(
63+
activities: ReadonlyArray<OrchestrationThreadActivity>,
64+
): OrchestrationThread {
65+
return {
66+
id: ThreadId.make("thread-1"),
67+
projectId: ProjectId.make("project-1"),
68+
title: "Voice thread",
69+
modelSelection: {
70+
instanceId: ProviderInstanceId.make("hermes"),
71+
model: "gpt-5.6",
72+
},
73+
runtimeMode: "full-access",
74+
interactionMode: "default",
75+
branch: null,
76+
worktreePath: null,
77+
latestTurn: null,
78+
createdAt: "2026-07-24T12:00:00.000Z",
79+
updatedAt: "2026-07-24T12:00:01.000Z",
80+
archivedAt: null,
81+
settledOverride: null,
82+
settledAt: null,
83+
deletedAt: null,
84+
messages: [message],
85+
proposedPlans: [],
86+
activities: [...activities],
87+
checkpoints: [],
88+
session: null,
89+
};
90+
}
91+
3692
describe("clientCompatibility", () => {
3793
it("keeps audio attachments for capable clients", () => {
3894
expect(
@@ -116,4 +172,53 @@ describe("clientCompatibility", () => {
116172
"attachments",
117173
);
118174
});
175+
176+
it("joins completed transcription activities into legacy thread snapshots", () => {
177+
const projected = projectOrchestrationThreadForClient(
178+
threadWithActivities([transcriptionActivity("ready", "The persisted transcript.")]),
179+
undefined,
180+
);
181+
182+
expect(projected.messages[0]).toMatchObject({
183+
text: "[Voice note transcript]\nThe persisted transcript.",
184+
});
185+
expect(projected.messages[0]).not.toHaveProperty("attachments");
186+
});
187+
188+
it("replaces a live legacy placeholder when transcription completes", () => {
189+
const projector = makeOrchestrationThreadStreamProjectorForClient(
190+
undefined,
191+
threadWithActivities([]),
192+
);
193+
const activity = transcriptionActivity("ready", "The live transcript.");
194+
const activityEvent = {
195+
sequence: 2,
196+
eventId: EventId.make("event-2"),
197+
aggregateKind: "thread" as const,
198+
aggregateId: ThreadId.make("thread-1"),
199+
occurredAt: activity.createdAt,
200+
commandId: null,
201+
causationEventId: null,
202+
correlationId: null,
203+
metadata: {},
204+
type: "thread.activity-appended" as const,
205+
payload: {
206+
threadId: ThreadId.make("thread-1"),
207+
activity,
208+
},
209+
};
210+
211+
expect(projector({ kind: "event", event: activityEvent })).toMatchObject({
212+
kind: "event",
213+
event: {
214+
sequence: 2,
215+
type: "thread.message-sent",
216+
payload: {
217+
messageId: MessageId.make("message-1"),
218+
text: "[Voice note transcript]\nThe live transcript.",
219+
replaceText: true,
220+
},
221+
},
222+
});
223+
});
119224
});

apps/server/src/orchestration/clientCompatibility.ts

Lines changed: 147 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,36 @@ import type {
99
OrchestrationThreadStreamItem,
1010
ThreadMessageSentPayload,
1111
} from "@t3tools/contracts";
12+
import {
13+
deriveVoiceTranscriptionsByAttachmentId,
14+
readVoiceTranscriptionActivity,
15+
readVoiceTranscriptionAttachmentId,
16+
type VoiceTranscriptionState,
17+
} from "@t3tools/shared/voiceTranscription";
1218

1319
const LEGACY_VOICE_NOTE_PLACEHOLDER = "[Voice note]";
20+
const LEGACY_VOICE_NOTE_FAILED_PLACEHOLDER = "[Voice note — transcription failed]";
1421

1522
function supportsAudioAttachments(
1623
capabilities: OrchestrationClientCapabilities | undefined,
1724
): boolean {
1825
return capabilities?.audioAttachments === true;
1926
}
2027

21-
function legacyVoiceNoteText(attachment: Extract<ChatAttachment, { type: "audio" }>): string {
22-
const transcript = attachment.transcript?.trim();
28+
function legacyVoiceNoteText(
29+
attachment: Extract<ChatAttachment, { type: "audio" }>,
30+
transcription?: VoiceTranscriptionState,
31+
): string {
32+
const transcript = (transcription?.transcript ?? attachment.transcript)?.trim();
33+
const status = transcription?.status ?? attachment.transcriptionStatus;
34+
if (status === "failed") return LEGACY_VOICE_NOTE_FAILED_PLACEHOLDER;
2335
return transcript ? `[Voice note transcript]\n${transcript}` : LEGACY_VOICE_NOTE_PLACEHOLDER;
2436
}
2537

2638
function projectMessageFields(input: {
2739
readonly text: string;
2840
readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
41+
readonly transcriptions?: ReadonlyMap<string, VoiceTranscriptionState> | undefined;
2942
}): {
3043
readonly changed: boolean;
3144
readonly text: string;
@@ -45,7 +58,9 @@ function projectMessageFields(input: {
4558
}
4659

4760
const supportedAttachments = attachments.filter((attachment) => attachment.type !== "audio");
48-
const voiceNoteText = audioAttachments.map(legacyVoiceNoteText).join("\n\n");
61+
const voiceNoteText = audioAttachments
62+
.map((attachment) => legacyVoiceNoteText(attachment, input.transcriptions?.get(attachment.id)))
63+
.join("\n\n");
4964
const text = input.text.trim().length > 0 ? `${input.text}\n\n${voiceNoteText}` : voiceNoteText;
5065
return {
5166
changed: true,
@@ -57,11 +72,12 @@ function projectMessageFields(input: {
5772
export function projectOrchestrationMessageForClient(
5873
message: OrchestrationMessage,
5974
capabilities: OrchestrationClientCapabilities | undefined,
75+
transcriptions?: ReadonlyMap<string, VoiceTranscriptionState>,
6076
): OrchestrationMessage {
6177
if (supportsAudioAttachments(capabilities)) {
6278
return message;
6379
}
64-
const projected = projectMessageFields(message);
80+
const projected = projectMessageFields({ ...message, transcriptions });
6581
if (!projected.changed) {
6682
return message;
6783
}
@@ -75,8 +91,9 @@ export function projectOrchestrationMessageForClient(
7591

7692
function projectThreadMessageEventForClient(
7793
payload: ThreadMessageSentPayload,
94+
transcriptions?: ReadonlyMap<string, VoiceTranscriptionState>,
7895
): ThreadMessageSentPayload {
79-
const projected = projectMessageFields(payload);
96+
const projected = projectMessageFields({ ...payload, transcriptions });
8097
if (!projected.changed) {
8198
return payload;
8299
}
@@ -95,10 +112,14 @@ export function projectOrchestrationThreadForClient(
95112
if (supportsAudioAttachments(capabilities)) {
96113
return thread;
97114
}
115+
const transcriptions = deriveVoiceTranscriptionsByAttachmentId(
116+
thread.messages,
117+
thread.activities,
118+
);
98119
return {
99120
...thread,
100121
messages: thread.messages.map((message) =>
101-
projectOrchestrationMessageForClient(message, capabilities),
122+
projectOrchestrationMessageForClient(message, capabilities, transcriptions),
102123
),
103124
};
104125
}
@@ -166,3 +187,123 @@ export function projectOrchestrationThreadStreamItemForClient(
166187
return item;
167188
}
168189
}
190+
191+
interface VoiceMessageState {
192+
readonly payload: ThreadMessageSentPayload;
193+
readonly attachmentIds: ReadonlyArray<string>;
194+
}
195+
196+
export function makeOrchestrationThreadStreamProjectorForClient(
197+
capabilities: OrchestrationClientCapabilities | undefined,
198+
seedThread?: OrchestrationThread,
199+
): (item: OrchestrationThreadStreamItem) => OrchestrationThreadStreamItem {
200+
if (supportsAudioAttachments(capabilities)) {
201+
return (item) => item;
202+
}
203+
204+
const messageById = new Map<string, VoiceMessageState>();
205+
const messageIdByAttachmentId = new Map<string, string>();
206+
const transcriptions = new Map<string, VoiceTranscriptionState>();
207+
208+
const registerMessage = (payload: ThreadMessageSentPayload): void => {
209+
const attachmentIds = (payload.attachments ?? [])
210+
.filter((attachment) => attachment.type === "audio")
211+
.map((attachment) => attachment.id);
212+
if (attachmentIds.length === 0) return;
213+
const previous = messageById.get(payload.messageId);
214+
for (const attachmentId of previous?.attachmentIds ?? []) {
215+
messageIdByAttachmentId.delete(attachmentId);
216+
}
217+
messageById.set(payload.messageId, { payload, attachmentIds });
218+
for (const attachmentId of attachmentIds) {
219+
messageIdByAttachmentId.set(attachmentId, payload.messageId);
220+
}
221+
};
222+
223+
const seed = (thread: OrchestrationThread): void => {
224+
for (const [attachmentId, transcription] of deriveVoiceTranscriptionsByAttachmentId(
225+
thread.messages,
226+
thread.activities,
227+
)) {
228+
transcriptions.set(attachmentId, transcription);
229+
}
230+
for (const message of thread.messages) {
231+
registerMessage({
232+
threadId: thread.id,
233+
messageId: message.id,
234+
role: message.role,
235+
text: message.text,
236+
...(message.attachments ? { attachments: [...message.attachments] } : {}),
237+
turnId: message.turnId,
238+
streaming: message.streaming,
239+
createdAt: message.createdAt,
240+
updatedAt: message.updatedAt,
241+
});
242+
}
243+
};
244+
245+
if (seedThread) seed(seedThread);
246+
247+
return (item) => {
248+
if (item.kind === "synchronized") return item;
249+
if (item.kind === "snapshot") {
250+
seed(item.snapshot.thread);
251+
return {
252+
...item,
253+
snapshot: projectOrchestrationThreadSnapshotForClient(item.snapshot, capabilities),
254+
};
255+
}
256+
257+
const event = item.event;
258+
if (event.type === "thread.message-sent") {
259+
registerMessage(event.payload);
260+
return {
261+
...item,
262+
event: projectOrchestrationEventForClient(event, capabilities),
263+
};
264+
}
265+
if (event.type !== "thread.activity-appended") {
266+
return {
267+
...item,
268+
event: projectOrchestrationEventForClient(event, capabilities),
269+
};
270+
}
271+
272+
const transcription = readVoiceTranscriptionActivity(event.payload.activity);
273+
const explicitAttachmentId = readVoiceTranscriptionAttachmentId(event.payload.activity);
274+
const fallbackAttachmentId = deriveVoiceTranscriptionsByAttachmentId(
275+
[...messageById.values()].map(({ payload }) => payload),
276+
[event.payload.activity],
277+
)
278+
.keys()
279+
.next().value;
280+
const attachmentId =
281+
explicitAttachmentId && messageIdByAttachmentId.has(explicitAttachmentId)
282+
? explicitAttachmentId
283+
: fallbackAttachmentId;
284+
if (!transcription || !attachmentId) return item;
285+
transcriptions.set(attachmentId, transcription);
286+
const messageId = messageIdByAttachmentId.get(attachmentId);
287+
const message = messageId ? messageById.get(messageId) : undefined;
288+
if (!message) return item;
289+
290+
return {
291+
kind: "event",
292+
event: {
293+
...event,
294+
type: "thread.message-sent",
295+
payload: {
296+
...projectThreadMessageEventForClient(
297+
{
298+
...message.payload,
299+
updatedAt: event.payload.activity.createdAt,
300+
replaceText: true,
301+
},
302+
transcriptions,
303+
),
304+
replaceText: true,
305+
},
306+
},
307+
};
308+
};
309+
}

0 commit comments

Comments
 (0)