Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export async function startProductionServer(
mcp_handler: () => Response.json({ error: "unavailable" }, { status: 503, headers: { "cache-control": "no-store" } }),
tasks: { authorization, codecRootSecret: config.codecKey, cursorSigningKeyset },
conversations: { authorization, codecRootSecret: config.codecKey, cursorSigningKeyset },
chat: { authorization, codecRootSecret: config.codecKey, cursorSigningKeyset },
settings: { authorization },
device_sessions: authorization,
device_ownership_key: config.codecKey,
transcription_source: createDeepgramTranscriptionSource({
Expand Down Expand Up @@ -186,7 +188,7 @@ export async function runProductionServer(
try {
const running = await startProductionServer(env, factories, controller.signal);
clearTimeout(startupDeadline);
if (!controller.signal.aborted) console.info("omi-platform ready: memories.read, tasks, device audio uploads");
if (!controller.signal.aborted) console.info("omi-platform ready: memories.read, tasks, device audio uploads, conversations.read, chat.read, settings");
await requested;
await running.stop();
return 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import {
MAIN_CHAT_CONVERSATION_ID,
composeChatSessionsIntoConversationPage,
type ChatConversationSessionItem,
} from "./chat-conversation-sessions";

const session = (
overrides: Partial<ChatConversationSessionItem> = {},
): ChatConversationSessionItem => Object.freeze({
id: MAIN_CHAT_CONVERSATION_ID,
title: "hello",
overview: "answer",
createdAt: 1000,
updatedAt: 2000,
startedAt: 1000,
finishedAt: null,
source: "chat",
status: "in_progress",
discarded: false,
starred: false,
visibility: "private",
isLocked: false,
folderId: null,
revision: null,
...overrides,
});

const page = (
items: ReadonlyArray<Record<string, unknown>>,
extras: Record<string, unknown> = {},
) => Object.freeze({
contractVersion: "1.0.0",
items,
window: Object.freeze({
status: "complete",
complete: true,
hasMore: false,
nextCursor: extras.nextCursor ?? null,
}),
completeness: Object.freeze({
version: "conversations-completeness-v1",
status: "complete",
reasons: Object.freeze([]),
}),
absence: items.length === 0 ? Object.freeze({ kind: "query_gap" }) : null,
});

describe("chat conversation composition", () => {
test("does not invent chat:chat-main when no granted sessions exist", () => {
expect(composeChatSessionsIntoConversationPage(page([]), [])).toBeNull();
expect(composeChatSessionsIntoConversationPage(page([{
id: "recording:one",
updatedAt: 3000,
title: "Recording",
}]), [])).toBeNull();
});

test("merges a persisted main chat session onto the listen page without changing the cursor", () => {
const listen = page([
{ id: "recording:newer", updatedAt: 4000, title: "Newer" },
{ id: "recording:older", updatedAt: 500, title: "Older" },
], { nextCursor: "listen-cursor" });
const composed = composeChatSessionsIntoConversationPage(listen, [session()]);
expect(composed?.window).toEqual(listen.window);
expect(composed?.absence).toBeNull();
expect(composed?.items.map((item) => item.id)).toEqual([
"recording:newer",
MAIN_CHAT_CONVERSATION_ID,
"recording:older",
]);
});

test("replaces a listen-claimed chat id with the granted chat session and clears an empty-page gap", () => {
const composed = composeChatSessionsIntoConversationPage(
page([]),
[session({ title: "saved prompt" })],
);
expect(composed?.absence).toBeNull();
expect(composed?.items).toEqual([session({ title: "saved prompt" })]);
expect(composeChatSessionsIntoConversationPage(
page([{ id: MAIN_CHAT_CONVERSATION_ID, updatedAt: 1, title: "stale" }]),
[session()],
)?.items).toEqual([session()]);
});

test("rejects a malformed listen envelope instead of dropping or inventing rows", () => {
expect(composeChatSessionsIntoConversationPage(null, [session()])).toBeNull();
expect(composeChatSessionsIntoConversationPage({ items: "rows" }, [session()])).toBeNull();
expect(composeChatSessionsIntoConversationPage(
page([{ id: "recording:one", updatedAt: "later" }]),
[session()],
)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export const MAIN_CHAT_CONVERSATION_ID = "chat:chat-main";

export type ChatConversationSessionItem = {
readonly id: typeof MAIN_CHAT_CONVERSATION_ID;
readonly title: string;
readonly overview: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly startedAt: number;
readonly finishedAt: number | null;
readonly source: "chat";
readonly status: "completed" | "in_progress";
readonly discarded: false;
readonly starred: false;
readonly visibility: "private";
readonly isLocked: false;
readonly folderId: null;
readonly revision: null;
};

export type ConversationEnvelopePage = {
readonly contractVersion: unknown;
readonly items: readonly Record<string, unknown>[];
readonly window: unknown;
readonly completeness: unknown;
readonly absence: { readonly kind: "query_gap" } | null;
};

const record = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;

const compareItems = (
left: Record<string, unknown>,
right: Record<string, unknown>,
): number => {
const leftUpdated = left.updatedAt;
const rightUpdated = right.updatedAt;
if (typeof leftUpdated === "number" && typeof rightUpdated === "number"
&& leftUpdated !== rightUpdated) {
return rightUpdated - leftUpdated;
}
const leftId = typeof left.id === "string" ? left.id : "";
const rightId = typeof right.id === "string" ? right.id : "";
return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
};

export const composeChatSessionsIntoConversationPage = (
page: unknown,
sessions: readonly ChatConversationSessionItem[],
): ConversationEnvelopePage | null => {
const envelope = record(page);
if (envelope === null || !Array.isArray(envelope.items) || sessions.length === 0) {
return null;
}
const items: Record<string, unknown>[] = [];
const sessionIds = new Set<string>(sessions.map((session) => session.id));
for (const item of envelope.items) {
const row = record(item);
if (row === null || typeof row.id !== "string" || typeof row.updatedAt !== "number") {
return null;
}
if (!sessionIds.has(row.id)) items.push(row);
}
items.push(...sessions);
items.sort(compareItems);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the listen cursor order when adding chat

Do not re-sort the returned listen page independently of its cursor. PostgreSQL pages recordings by ascending durable conversation_sequence, and the runtime saves the next cursor against that original sequence, but this line reorders only the first page by descending updatedAt; after the client appends later pages, recordings can appear in a different order from the cursor chain, with an old injected chat row also preceding newer recordings that have not yet been fetched. Either preserve the listen rows' order when inserting the chat item or paginate the combined ordering.

Useful? React with 👍 / 👎.

return Object.freeze({
contractVersion: envelope.contractVersion,
items: Object.freeze(items),
window: envelope.window,
completeness: envelope.completeness,
absence: items.length === 0 ? { kind: "query_gap" } : null,
});
};
4 changes: 4 additions & 0 deletions backends/example-platform/apps/service/memory-service-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const createMemoryServiceApp = (
tasks?: { readonly executeRequest: (request: Request) => Promise<Response> },
deviceSessions?: { readonly fetch: (request: Request) => Promise<Response> },
conversations?: {readonly executeRequest: (request: Request) => Promise<Response>},
chat?: {readonly executeRequest: (request: Request) => Promise<Response>},
settings?: {readonly executeRequest: (request: Request) => Promise<Response>},
): Hono => {
const app = createServiceApp(mcpHandler, observability);
registerMemoryRoutes(app, memoryRoutes);
Expand All @@ -40,5 +42,7 @@ export const createMemoryServiceApp = (
app.get("/v1/device-sessions/:id/transcript", context => deviceSessions.fetch(context.req.raw));
}
if (conversations) app.get("/v1/conversations", context => conversations.executeRequest(context.req.raw));
if (chat) app.get("/v1/chat-messages", context => chat.executeRequest(context.req.raw));
if (settings) app.get("/v1/settings", context => settings.executeRequest(context.req.raw));
return app;
};
28 changes: 20 additions & 8 deletions backends/example-platform/apps/service/routes/chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import type {
ChatMessageRecord,
ChatMessagesStore,
StoredChatMessage,
WritableChatMessageType,
} from "../stores/chat-messages-store";
import type {
Expand Down Expand Up @@ -302,7 +303,7 @@ export const chatMessagePayloadHash = (create: ParsedCreate): string => {
return `sha256:${createHash("sha256").update(canonicalJson(subject), "utf8").digest("hex")}`;
};

const parseHistoryQuery = (request: Request): {
export const parseHistoryQuery = (request: Request): {
readonly limit: number;
readonly olderCursor: string | null;
} | null => {
Expand All @@ -328,7 +329,7 @@ const TERMINAL_KINDS = new Set(["done", "failed", "cancelled"]);
const isTerminal = (event: ChatGenerationEvent): boolean => TERMINAL_KINDS.has(event.frame.kind);

type ChatGenerationOutcome = "completed" | "cancelled" | null;
type ChatWireMessage = ChatMessageRecord & {
export type ChatWireMessage = ChatMessageRecord & {
readonly generationOutcome: ChatGenerationOutcome;
};

Expand All @@ -345,19 +346,16 @@ const sameCanonicalMessage = (left: ChatMessageRecord, right: ChatMessageRecord)
* row deliberately does not duplicate that state, so an orphan or a mismatched
* terminal fails closed instead of silently becoming a completed answer.
*/
const projectHistoryMessage = (
accountId: string,
export const projectLoadedHistoryMessage = (
message: ChatMessageRecord,
messages: ChatMessagesStore,
events: ChatGenerationEventsStore,
stored: StoredChatMessage | null,
generationEvents: readonly ChatGenerationEvent[] | null,
): ChatWireMessage => {
if (message.sender !== "ai") return withGenerationOutcome(message, null);
const stored = messages.readMessage(accountId, message.id);
if (stored === null || stored.generationId === null
|| !sameCanonicalMessage(stored.message, message)) {
throw new TypeError("canonical assistant has no matching generation identity");
}
const generationEvents = events.listAfter(accountId, stored.generationId, null);
const terminals = generationEvents?.filter(isTerminal) ?? [];
if (terminals.length !== 1) {
throw new TypeError("canonical assistant has no unique terminal event");
Expand All @@ -374,6 +372,20 @@ const projectHistoryMessage = (
);
};

export const projectHistoryMessage = (
accountId: string,
message: ChatMessageRecord,
messages: Pick<ChatMessagesStore, "readMessage">,
events: Pick<ChatGenerationEventsStore, "listAfter">,
): ChatWireMessage => {
if (message.sender !== "ai") return projectLoadedHistoryMessage(message, null, null);
const stored = messages.readMessage(accountId, message.id);
const generationEvents = stored?.generationId === undefined || stored.generationId === null
? null
: events.listAfter(accountId, stored.generationId, null);
return projectLoadedHistoryMessage(message, stored, generationEvents);
};

type ExternalChatGenerationEvent = ChatGenerationEvent & {
readonly frame: Exclude<ChatGenerationEvent["frame"], { readonly kind: "accepted" }>;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Persisted chat history reads

The deployed service mounts `GET /v1/chat-messages` through the same admission,
readiness and drain boundary as the other REST routes. It verifies the original
Firebase identity and requires a registered active credential with the exact
`chat.read` grant. A `memories.read`, `tasks.read` or `conversations.read` grant
does not confer this read permission. Missing or revoked grants return 403; they
never become an empty successful transcript. Deployment still needs the
authoritative account, control, credential and grant records described in
`deployed-entry.md`.

Migration 0055 adds account-owned chat message rows and generation events. History
uses the insertion snapshot and opaque HMAC cursor already used by the local
service. An empty granted account is an honest empty page with the existing
attachment capability advertisement. Assistant rows require a unique terminal
generation event; an orphan or mismatched terminal is 503 rather than a completed
answer. Human rows keep `generationOutcome: null`.

`POST /v1/chat-messages` and generation SSE are not mounted. Unmounted writes stay
404 `{error:"not_found"}`. Do not invent chat quotas or mount admission until a
real entitlement producer exists.

Verification uses `bun run check:deployed` for grant denial, empty-page shape,
projection fail-closed behavior, string generation frames, route pairing and the
production import closure, and `bun run test:postgres` for actual application-role
reads, account isolation, unique-terminal assistant outcomes, grant revocation
and conversation-list composition of `chat:chat-main`. Docker is
required for that real PostgreSQL 18.4 gate. These tests use isolated synthetic
identities; they do not activate a deployed user or prove live generation.
Do not apply migrations 55-56 or deploy this entry until the existing operator
migration sequence can run against based-hardware-dev. A process built from this
manifest will not become ready against a database that still has only
migrations 1–54.
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ Migration 0053 removes the old whole-account function and introduces a distinct
metadata read; an old process calling the retired function fails unavailable rather
than returning an empty successful history during a mixed-revision rollout.

This is the persisted Listen/recording list, not a production chat history store.
Chat conversations, editable metadata, folders and star mutations still need their
own actual persisted domain composition. Full transcript data remains on the
existing account-scoped device-session transcript route.
This is the persisted Listen/recording list, plus granted main-chat sessions.
First-page envelope reads that also hold `chat.read` include `chat:chat-main` when
that account actually has main-session messages. Missing `chat.read`, revoked
grants, and empty chat history never invent that row. Later cursor pages keep the
Listen sequence and do not repeat the chat session. Chat writes, editable
metadata, folders and star mutations still need their own persisted domain
composition. Full recording transcript data remains on the existing
account-scoped device-session transcript route.

Verification uses `bun run check:deployed` for projection, expiry/cancellation,
route and shell contracts, and `bun run test:postgres` for actual application-role
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ Build from this backend directory with
`docker build --platform linux/amd64 -t omi-platform-dev .`.

This increment serves authenticated canonical memory reads, task reads and
mutations, and indexed device audio uploads. All three use the same database
generation and Firebase authorization configuration inside the readiness and
shutdown boundary. Audio upload completion does not certify transcription or
conversation formation. Chat and authenticated MCP remain unavailable; MCP
returns 503. This is not full backend parity or production qualification.
mutations, indexed device audio uploads, conversation reads, chat history
reads, granted main-chat session composition on the first conversation page, and Settings GET. All of them use the same database generation and Firebase authorization
configuration inside the readiness and shutdown boundary. Audio upload completion
does not certify transcription or conversation formation. Chat writes, generation
SSE, Settings identity/entitlement producers, attachments and authenticated MCP
remain unavailable; MCP returns 503. Missing `chat.read` is 403, not an empty
successful transcript. Signed-in Settings without a producer is 503, not a 200
profile invented from the Firebase token. This is not full backend parity or production qualification.

Required configuration:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Production Settings reads

The deployed service mounts `GET /v1/settings` through the same admission,
readiness and drain boundary as the other REST routes. Absent credentials return
the signed-out envelope `{identity:null,entitlement:null}`. Present invalid
credentials are 401. A verified Firebase identity without an owner-backed profile
and entitlement producer is 503 `{error:"service_unavailable"}` with
`retry-after: 60`. Token claims are never projected as display names, emails, or
plans. PostgreSQL account/grant rows are not a Settings producer.

Mutations stay unmounted. Unmounted writes stay 404 `{error:"not_found"}`. Do not
invent identity, billing, or usage to satisfy the page.

Verification uses `bun run check:deployed` for signed-out, unauthorized, verified
unavailable, grammar, pairing and the production import closure. These tests use
isolated synthetic identities; they do not activate a deployed user or prove a
billing producer.
Loading
Loading