Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
477537b
feat(voice): port the pure realtime core
2witstudios Aug 10, 2026
27cbadf
Merge pull request #2387 from 2witstudios/pu/rt-core
2witstudios Aug 10, 2026
54670fd
feat(voice): adapt the tool registry to realtime function definitions
2witstudios Aug 10, 2026
4738a46
Merge pull request #2388 from 2witstudios/pu/rt-tools
2witstudios Aug 10, 2026
159ed69
feat(voice): price gpt-realtime token usage and add realtime session …
2witstudios Aug 10, 2026
598f741
Merge pull request #2391 from 2witstudios/pu/rt-pricing
2witstudios Aug 10, 2026
e9d2d76
fix(voice): pin gpt-realtime-2.1 now that the project allowlists it
2witstudios Aug 10, 2026
1369105
Merge pull request #2392 from 2witstudios/pu/rt-repin-21
2witstudios Aug 10, 2026
59ab281
feat(voice): get our server onto the realtime call, authenticated, an…
2witstudios Aug 10, 2026
d9d3aff
Merge pull request #2393 from 2witstudios/pu/rt-server-call
2witstudios Aug 10, 2026
86ca8b8
feat(voice): what the server does while it is on the call
2witstudios Aug 11, 2026
aced902
Merge pull request #2395 from 2witstudios/pu/rt-server-behavior
2witstudios Aug 11, 2026
ca68c69
Merge remote-tracking branch 'origin/master' into sync-master
2witstudios Aug 11, 2026
1ffabe7
chore(db): renumber the voice source-column migration to 0258 after s…
2witstudios Aug 11, 2026
e4d0240
feat(voice): the browser holds the mic and the speaker, and nothing else
2witstudios Aug 11, 2026
d5d0934
test(voice): guard WHERE the session is mounted, not just that it sur…
2witstudios Aug 11, 2026
3ec364b
Merge pull request #2396 from 2witstudios/pu/rt-client
2witstudios Aug 11, 2026
2db59d5
feat(voice): the talk button, and voice as a mode on the chat surface
2witstudios Aug 11, 2026
0b7159a
Merge pull request #2397 from 2witstudios/pu/rt-ui
2witstudios Aug 11, 2026
b0657cc
feat(voice): retire the conversational STT->LLM->TTS loop
2witstudios Aug 11, 2026
91767b1
Merge pull request #2398 from 2witstudios/pu/rt-retire
2witstudios Aug 11, 2026
e664a3b
fix(voice): make the realtime caps real, and hang up when we stop wat…
2witstudios Aug 11, 2026
ba27f78
fix(voice): a call talks to the assistant it says it is talking to
2witstudios Aug 11, 2026
6baaf0d
fix(voice): a failed agent read costs the identity, not the history
2witstudios Aug 11, 2026
4488b23
fix(voice): a failing meter settle must not block the teardown it pre…
2witstudios Aug 11, 2026
af64d7b
docs(changelog): note that a call runs as the assistant you picked, a…
2witstudios Aug 11, 2026
cbf576c
test(voice): pin the instruction builder directly
2witstudios Aug 11, 2026
fe40c7b
fix(voice): a refused call says it was refused, not that the wire broke
2witstudios Aug 11, 2026
255c25d
test(voice): pin the transcript write's in-transaction guard
2witstudios Aug 11, 2026
e0c4029
fix(tenant): carry messages.source in the tenant export
2witstudios Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ CSRF_SECRET=your_csrf_secret_here_generate_a_secure_random_string
ENCRYPTION_KEY=your_encryption_key_here_32_byte_hex_string

# Application URLs
# WEB_APP_URL is also used for Origin header validation (CSRF defense-in-depth)
# WEB_APP_URL is also used for Origin header validation (CSRF defense-in-depth),
# and — since audio-native voice — as apps/realtime's address for the web tier: a
# live call calls back to {WEB_APP_URL}/api/internal/voice/bridge to run tools and
# persist transcripts. Unset, a voice call still connects and still bills; it just
# runs without tools or a transcript.
WEB_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_ADMIN_APP_URL=http://localhost:3005
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ All notable user-facing changes to PageSpace are documented here. Format follows

## [Unreleased]

### Added

- **A microphone in the top bar, on every page, that talks to whichever assistant you are already
looking at** — there is no separate voice screen and nothing to set up first. Press it on a page
and the assistant sidebar opens in voice mode, talking to the agent you had selected there; press
it on the dashboard and it talks to the assistant already in the middle of your screen. Nothing
connects until you press it.
- **A spoken conversation is the same conversation you can read and type in** — voice is a way into
a thread that already existed and still exists after you hang up. What is said appears in that
thread as ordinary messages while the call is running, marked with a small microphone so you can
tell later what was spoken and what was typed. There is no separate voice history to go looking
for, and nothing to replay.
- **The call survives you walking around the app** — moving between pages does not end it or move it
to a different assistant; it just tells the assistant where you now are. Closing the sidebar
minimizes the call rather than hanging up, and the top-bar microphone stays lit so you can get
back to it. Deliberately choosing a different agent in the sidebar's switcher does move the call,
because that is a different conversation. Ending a call is the End button on the call itself, and
refreshing the page ends it too.
- **The assistant on the call is the one you picked, with the instructions and the tools its owner
gave it** — an agent you built to answer a particular way answers that way out loud too, and one
whose tools you restricted cannot reach past them just because the conversation is spoken.
- **A call you cannot have does not start** — running out of credit, or already having as many calls
open as your plan allows, now says so and stops, instead of connecting anyway and leaving you
talking to something nobody was counting.
- **When voice cannot start, it says which problem you have** — a microphone you declined is
different from a microphone you do not have, and the two now get different advice and only the
fixable one offers to try again. If the call connects but the transcript service does not, the
call says so rather than letting you talk for ten minutes into something that was never going to
be saved.

### Fixed

- **A second agent in a session stays put instead of flashing up and vanishing** — opening a chat
Expand All @@ -31,6 +61,15 @@ All notable user-facing changes to PageSpace are documented here. Format follows

### Changed

- **Voice mode is now audio-native, and the old hands-free mic in the chat box is gone** — talking
to an assistant used to mean recording a clip, having it transcribed into text, and having the
reply read back to you. Everything about how you said it — pace, hesitation, the moment you cut
in — was thrown away before the assistant ever saw your words, and you had to wait for a whole
reply to be written before hearing any of it. The assistant now hears you directly and answers in
the same breath, so you can interrupt it and it stops. The way in is the microphone in the top
bar, on every page; the hands-free button that used to sit in the chat box next to the model
picker has been removed, along with its separate panel of voice settings for reading speed,
spoken voice and tap-to-speak.
- **What the AI has learned about you is now a set of pages you can read and edit** — your
personalization lived in three text boxes in Settings that a nightly process also wrote to, with
no indication of what it had added or why. It is now three pages in a Memory folder in your Home
Expand Down
110 changes: 110 additions & 0 deletions apps/realtime/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@ vi.mock('../kick-handler', () => ({
handleKickRequest: mockHandleKickRequest,
}));

// The voice attach endpoint receives a LIVE OpenAI ephemeral credential, so
// what is asserted here is the wiring — that the signature gate runs before the
// handler sees the body — not the handler's own behaviour (that has its own
// suite). Mocked so no socket is ever opened by these tests.
const mockHandleRealtimeAttachRequest = vi.fn().mockResolvedValue({
status: 200,
body: { success: true, callId: 'rtc_test' },
});

vi.mock('../voice/attach-handler', () => ({
handleRealtimeAttachRequest: mockHandleRealtimeAttachRequest,
defaultAttachHandlerDeps: { registry: {}, attach: vi.fn() },
}));

// ---------------------------------------------------------------------------
// 2. Mock http.createServer and Socket.IO Server to capture callbacks
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -700,6 +714,102 @@ describe('requestListener - /api/kick', () => {
});
});

describe('requestListener - /api/realtime/attach', () => {
const attachBody = JSON.stringify({
callId: 'rtc_abc123',
secret: 'ek_secret_value',
userId: 'u1',
tools: [],
});

beforeEach(() => {
vi.clearAllMocks();
mockHandleRealtimeAttachRequest.mockResolvedValue({
status: 200,
body: { success: true, callId: 'rtc_abc123' },
});
});

it('given a valid signature, should delegate the RAW body to the attach handler', async () => {
vi.mocked(verifyBroadcastSignature).mockReturnValue(true);

const req = createMockReq({
method: 'POST',
url: '/api/realtime/attach',
headers: { 'x-broadcast-signature': 'valid-sig' },
});
const res = createMockRes();

capturedRequestListener!(req, res);
req._emit('data', Buffer.from(attachBody));
req._emit('end');

await new Promise(resolve => setTimeout(resolve, 0));

// The raw body, byte for byte — it is what the HMAC was computed over.
expect(mockHandleRealtimeAttachRequest).toHaveBeenCalledWith(expect.anything(), attachBody);
expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
});

it('given NO signature, should 401 without the handler ever seeing the secret', async () => {
const req = createMockReq({
method: 'POST',
url: '/api/realtime/attach',
headers: {},
});
const res = createMockRes();

capturedRequestListener!(req, res);
req._emit('data', Buffer.from(attachBody));
req._emit('end');

await new Promise(resolve => setTimeout(resolve, 0));

expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' });
expect(mockHandleRealtimeAttachRequest).not.toHaveBeenCalled();
});

it('given an INVALID signature, should 401 without the handler ever seeing the secret', async () => {
vi.mocked(verifyBroadcastSignature).mockReturnValue(false);

const req = createMockReq({
method: 'POST',
url: '/api/realtime/attach',
headers: { 'x-broadcast-signature': 'forged-sig' },
});
const res = createMockRes();

capturedRequestListener!(req, res);
req._emit('data', Buffer.from(attachBody));
req._emit('end');

await new Promise(resolve => setTimeout(resolve, 0));

expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' });
expect(mockHandleRealtimeAttachRequest).not.toHaveBeenCalled();
});

it('given the handler rejects, should 500 rather than leaving the request hanging', async () => {
vi.mocked(verifyBroadcastSignature).mockReturnValue(true);
mockHandleRealtimeAttachRequest.mockRejectedValue(new Error('boom'));

const req = createMockReq({
method: 'POST',
url: '/api/realtime/attach',
headers: { 'x-broadcast-signature': 'valid-sig' },
});
const res = createMockRes();

capturedRequestListener!(req, res);
req._emit('data', Buffer.from(attachBody));
req._emit('end');

await new Promise(resolve => setTimeout(resolve, 0));

expect(res.writeHead).toHaveBeenCalledWith(500, { 'Content-Type': 'application/json' });
});
});

describe('requestListener - 404', () => {
it('given unknown route, should return 404', () => {
const req = createMockReq({
Expand Down
Loading