-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat: restore production chat reads, honest Settings GET, and granted chat conversation composition #12914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: restore production chat reads, honest Settings GET, and granted chat conversation composition #12914
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1bae503
feat: persist PostgreSQL chat history under chat.read
cursoragent 67cbf8c
fix: show grant-denied chat and saved-data copy without empty libraries
cursoragent 6b31c5f
feat: mount production Settings GET without inventing identity
cursoragent d557704
fix: encode real-PG chat terminals as parsed jsonb
cursoragent ec68aaa
feat: compose granted chat sessions into conversation reads
cursoragent 59fc4c4
fix: load main chat history in conversation detail
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
backends/example-platform/apps/service/composition/chat-conversation-sessions.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
75 changes: 75 additions & 0 deletions
75
backends/example-platform/apps/service/composition/chat-conversation-sessions.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| return Object.freeze({ | ||
| contractVersion: envelope.contractVersion, | ||
| items: Object.freeze(items), | ||
| window: envelope.window, | ||
| completeness: envelope.completeness, | ||
| absence: items.length === 0 ? { kind: "query_gap" } : null, | ||
| }); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
backends/example-platform/docs/memory-productionization/chat-deployed.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
backends/example-platform/docs/memory-productionization/settings-deployed.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 descendingupdatedAt; 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 👍 / 👎.