311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { createLiveEventDelivery, createLiveEventProjector } from "../src/protocol-live"
|
|
import type { VoiceProtocolEvent } from "../src/protocol"
|
|
|
|
const frames = [
|
|
{ type: "session.started", session: {} },
|
|
{ type: "session.context.appended", start_ms: 0, end_ms: 0 },
|
|
{ type: "output_audio.delta", audio: "AAA=", start_ms: 0, end_ms: 1 },
|
|
{
|
|
type: "input_transcript.added",
|
|
start_ms: 0,
|
|
end_ms: 100,
|
|
item: { id: "input-1", type: "input_transcript", text: " Hello" },
|
|
},
|
|
{ type: "turn.created", turn: { id: "user-turn", role: "user", transcript: " Hello" } },
|
|
{
|
|
type: "input_transcript.added",
|
|
start_ms: 100,
|
|
end_ms: 200,
|
|
item: { id: "input-2", type: "input_transcript", text: ", this" },
|
|
},
|
|
{ type: "turn.delta", turn_id: "user-turn", delta: ", this" },
|
|
{
|
|
type: "output_transcript.added",
|
|
start_ms: 200,
|
|
end_ms: 300,
|
|
item: { id: "output-1", type: "output_transcript", text: " Copy that" },
|
|
},
|
|
{
|
|
type: "input_transcript.added",
|
|
start_ms: 200,
|
|
end_ms: 300,
|
|
item: { id: "input-3", type: "input_transcript", text: " is a test" },
|
|
},
|
|
{
|
|
type: "output_transcript.added",
|
|
start_ms: 300,
|
|
end_ms: 400,
|
|
item: { id: "output-2", type: "output_transcript", text: ", test" },
|
|
},
|
|
{ type: "turn.delta", turn_id: "user-turn", delta: " is a test" },
|
|
{ type: "turn.done", turn: { id: "user-turn", role: "user", transcript: " Hello, this is a test" } },
|
|
{ type: "turn.created", turn: { id: "assistant-turn", role: "assistant", transcript: " Copy that, test" } },
|
|
{
|
|
type: "output_transcript.added",
|
|
start_ms: 400,
|
|
end_ms: 500,
|
|
item: { id: "output-3", type: "output_transcript", text: " received" },
|
|
},
|
|
{ type: "turn.delta", turn_id: "assistant-turn", delta: " received" },
|
|
{
|
|
type: "output_transcript.added",
|
|
start_ms: 500,
|
|
end_ms: 600,
|
|
item: { id: "output-4", type: "output_transcript", text: " loud and clear." },
|
|
},
|
|
{ type: "turn.delta", turn_id: "assistant-turn", delta: " loud and clear." },
|
|
{
|
|
type: "turn.done",
|
|
turn: { id: "assistant-turn", role: "assistant", transcript: " Copy that, test received loud and clear." },
|
|
},
|
|
] as const
|
|
|
|
describe("Live event projector", () => {
|
|
test("replays progressive transcripts without schema errors or duplicate text", () => {
|
|
const project = createLiveEventProjector()
|
|
const events = frames.flatMap((frame) => project(JSON.stringify(frame)).events)
|
|
|
|
expect(events.filter((event) => event.type === "error")).toEqual([])
|
|
expect(events.filter((event) => event.type === "user.committed")).toEqual([
|
|
{ type: "user.committed", id: "input-1" },
|
|
])
|
|
expect(events.filter(isUserTranscript)).toEqual([
|
|
{ type: "user.transcript", id: "input-1", text: "Hello", final: false },
|
|
{ type: "user.transcript", id: "input-1", text: "Hello, this", final: false },
|
|
{ type: "user.transcript", id: "input-1", text: "Hello, this is a test", final: false },
|
|
{ type: "user.transcript", id: "input-1", text: "Hello, this is a test", final: true },
|
|
])
|
|
expect(
|
|
events
|
|
.filter(isAssistantDelta)
|
|
.map((event) => event.delta)
|
|
.join(""),
|
|
).toBe(" Copy that, test received loud and clear.")
|
|
expect(events.filter((event) => event.type === "assistant.done")).toEqual([
|
|
{ type: "assistant.done", awaitingWork: false },
|
|
])
|
|
expect(events.filter((event) => event.type === "assistant.audio")).toHaveLength(1)
|
|
})
|
|
|
|
test("reports malformed frames instead of throwing", () => {
|
|
expect(createLiveEventProjector()(JSON.stringify({ type: "turn.created", turn: { id: 1 } })).events).toEqual([
|
|
{ type: "error", message: "Received an invalid Live API event." },
|
|
])
|
|
})
|
|
|
|
test("preserves context acknowledgement correlation IDs", () => {
|
|
expect(
|
|
createLiveEventProjector()(
|
|
JSON.stringify({ type: "session.context.appended", event_id: "event-notification" }),
|
|
),
|
|
).toMatchObject({
|
|
type: "session.context.appended",
|
|
eventID: "event-notification",
|
|
})
|
|
})
|
|
|
|
test("replaces corrected assistant transcript snapshots", () => {
|
|
const project = createLiveEventProjector()
|
|
const events = [
|
|
project(
|
|
JSON.stringify({
|
|
type: "output_transcript.added",
|
|
item: { id: "output-1", type: "output_transcript", text: " Hello world" },
|
|
}),
|
|
),
|
|
project(
|
|
JSON.stringify({
|
|
type: "turn.created",
|
|
turn: { id: "assistant-turn", role: "assistant", transcript: " Hello there" },
|
|
}),
|
|
),
|
|
].flatMap((result) => result.events)
|
|
|
|
expect(events.filter((event) => event.type.startsWith("assistant.transcript"))).toEqual([
|
|
{ type: "assistant.transcript.delta", delta: " Hello world" },
|
|
{ type: "assistant.transcript", text: " Hello there" },
|
|
])
|
|
})
|
|
|
|
test("keeps the space between consecutive assistant turns", () => {
|
|
const project = createLiveEventProjector()
|
|
const turn = (id: string, text: string) => [
|
|
{ type: "turn.created", turn: { id, role: "assistant", transcript: "" } },
|
|
{ type: "output_transcript.added", item: { id: `${id}-o`, type: "output_transcript", text } },
|
|
{ type: "turn.delta", turn_id: id, delta: text },
|
|
{ type: "turn.done", turn: { id, role: "assistant", transcript: text } },
|
|
]
|
|
const events = [...turn("t1", ' Session "Fix auth bug" is ready.'), ...turn("t2", " Next up, tests.")].flatMap(
|
|
(frame) => project(JSON.stringify(frame)).events,
|
|
)
|
|
|
|
// Concatenating the delta stream is what the UI and any transcript consumer does; the
|
|
// turn boundary must survive it rather than welding "ready.Next" together.
|
|
const transcript = events
|
|
.filter(isAssistantDelta)
|
|
.map((event) => event.delta)
|
|
.join("")
|
|
expect(transcript).toBe(' Session "Fix auth bug" is ready. Next up, tests.')
|
|
expect(transcript).not.toContain("ready.Next")
|
|
expect(events.filter((event) => event.type === "assistant.done")).toHaveLength(2)
|
|
})
|
|
|
|
test("emits no duplicate text when fragments and turn deltas overlap across turns", () => {
|
|
const project = createLiveEventProjector()
|
|
const frames = [
|
|
{ type: "turn.created", turn: { id: "t1", role: "assistant", transcript: "" } },
|
|
{ type: "output_transcript.added", item: { id: "a", type: "output_transcript", text: " First" } },
|
|
{ type: "turn.delta", turn_id: "t1", delta: " First" },
|
|
{ type: "output_transcript.added", item: { id: "b", type: "output_transcript", text: " turn." } },
|
|
{ type: "turn.delta", turn_id: "t1", delta: " turn." },
|
|
{ type: "turn.done", turn: { id: "t1", role: "assistant", transcript: " First turn." } },
|
|
{ type: "turn.created", turn: { id: "t2", role: "assistant", transcript: "" } },
|
|
{ type: "output_transcript.added", item: { id: "c", type: "output_transcript", text: " Second turn." } },
|
|
{ type: "turn.delta", turn_id: "t2", delta: " Second turn." },
|
|
{ type: "turn.done", turn: { id: "t2", role: "assistant", transcript: " Second turn." } },
|
|
]
|
|
const events = frames.flatMap((frame) => project(JSON.stringify(frame)).events)
|
|
|
|
expect(events.filter((event) => event.type === "assistant.transcript")).toEqual([])
|
|
expect(
|
|
events
|
|
.filter(isAssistantDelta)
|
|
.map((event) => event.delta)
|
|
.join(""),
|
|
).toBe(" First turn. Second turn.")
|
|
})
|
|
|
|
test("projects Responses delegation function calls without rejecting reasoning items", () => {
|
|
const project = createLiveEventProjector()
|
|
expect(
|
|
project(
|
|
JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: { id: "reasoning-1", type: "reasoning" },
|
|
}),
|
|
).events,
|
|
).toEqual([{ type: "debug", message: "response.output_item.done" }])
|
|
expect(
|
|
project(
|
|
JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: {
|
|
id: "function-1",
|
|
type: "function_call",
|
|
name: "find_sessions",
|
|
call_id: "call-1",
|
|
arguments: "",
|
|
},
|
|
}),
|
|
).events,
|
|
).toEqual([
|
|
{ type: "debug", message: "response.output_item.added" },
|
|
{ type: "tool.started", id: "call-1", name: "find_sessions" },
|
|
])
|
|
expect(
|
|
project(
|
|
JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
id: "function-1",
|
|
type: "function_call",
|
|
name: "find_sessions",
|
|
call_id: "call-1",
|
|
arguments: '{"scope":"current_project"}',
|
|
},
|
|
}),
|
|
).events,
|
|
).toEqual([
|
|
{ type: "debug", message: "response.output_item.done" },
|
|
{
|
|
type: "work.requested",
|
|
request: { id: "call-1", name: "find_sessions", input: { scope: "current_project" } },
|
|
},
|
|
])
|
|
expect(
|
|
project(
|
|
JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
id: "message-1",
|
|
type: "message",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Two sessions found." }],
|
|
role: "assistant",
|
|
},
|
|
}),
|
|
).events,
|
|
).toEqual([{ type: "debug", message: "response.output_item.done" }])
|
|
})
|
|
|
|
test("projects client delegation requests for the local controller", () => {
|
|
expect(
|
|
createLiveEventProjector()(
|
|
JSON.stringify({
|
|
type: "delegation.created",
|
|
item: {
|
|
id: "delegation-1",
|
|
type: "delegation",
|
|
target: "client",
|
|
content: [{ type: "input_text", text: "Inspect the voice package" }],
|
|
},
|
|
}),
|
|
).events,
|
|
).toEqual([
|
|
{ type: "debug", message: "delegation.created" },
|
|
{ type: "delegation.requested", request: { id: "delegation-1", text: "Inspect the voice package" } },
|
|
])
|
|
})
|
|
|
|
test("rejects malformed function arguments without hanging the delegation", () => {
|
|
expect(
|
|
createLiveEventProjector()(
|
|
JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
id: "function-1",
|
|
type: "function_call",
|
|
name: "find_sessions",
|
|
call_id: "call-invalid",
|
|
arguments: "not json",
|
|
},
|
|
}),
|
|
).events,
|
|
).toEqual([
|
|
{ type: "debug", message: "response.output_item.done" },
|
|
{ type: "tool.started", id: "call-invalid", name: "find_sessions" },
|
|
{ type: "error", message: "Received invalid arguments for Live tool find_sessions." },
|
|
{
|
|
type: "work.rejected",
|
|
request: { id: "call-invalid", name: "find_sessions", input: {} },
|
|
output: { status: "error", message: "Invalid arguments for tool find_sessions." },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("waits for a quiet drain after turn completion and late audio", async () => {
|
|
const events: VoiceProtocolEvent[] = []
|
|
const delivery = createLiveEventDelivery({ emit: (event) => events.push(event), drainMs: 20 })
|
|
delivery.push({ type: "assistant.done", awaitingWork: false })
|
|
await Bun.sleep(10)
|
|
delivery.push({ type: "assistant.audio", audio: Buffer.alloc(48) })
|
|
await Bun.sleep(12)
|
|
expect(events.map((event) => event.type)).toEqual(["assistant.audio"])
|
|
await Bun.sleep(12)
|
|
expect(events.map((event) => event.type)).toEqual(["assistant.audio", "assistant.done"])
|
|
delivery.close()
|
|
})
|
|
})
|
|
|
|
function isUserTranscript(
|
|
event: VoiceProtocolEvent,
|
|
): event is Extract<VoiceProtocolEvent, { readonly type: "user.transcript" }> {
|
|
return event.type === "user.transcript"
|
|
}
|
|
|
|
function isAssistantDelta(
|
|
event: VoiceProtocolEvent,
|
|
): event is Extract<VoiceProtocolEvent, { readonly type: "assistant.transcript.delta" }> {
|
|
return event.type === "assistant.transcript.delta"
|
|
}
|